From b982dcbc0e4c49360500842ea51888c0e50c069d Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 6 Nov 2025 13:01:35 +0200 Subject: [PATCH 1/5] Add DIGEST command support with tests --- redis/commands/cluster.py | 1 + redis/commands/core.py | 15 +++++++++++ tests/test_asyncio/test_commands.py | 42 +++++++++++++++++++++++++++++ tests/test_commands.py | 42 +++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/redis/commands/cluster.py b/redis/commands/cluster.py index 13f2035265..81883b67b4 100644 --- a/redis/commands/cluster.py +++ b/redis/commands/cluster.py @@ -61,6 +61,7 @@ "GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", + "DIGEST", "GET", "GETBIT", "GETRANGE", diff --git a/redis/commands/core.py b/redis/commands/core.py index 7b9ba7295c..5d3763b9a6 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1835,6 +1835,21 @@ def expiretime(self, key: str) -> int: """ return self.execute_command("EXPIRETIME", key) + def digest(self, name: KeyT) -> Optional[str]: + """ + Return the digest of the value stored at the specified key. + + Returns: + - None if the key does not exist + - (bulk string) the XXH3 digest of the value as a hex string + Raises: + - ResponseError if key exists but is not a string + + For more information, see https://redis.io/commands/digest + """ + # Bulk string response is already handled (bytes/str based on decode_responses) + return self.execute_command("DIGEST", name) + def get(self, name: KeyT) -> ResponseT: """ Return the value at key ``name``, or None if the key doesn't exist diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 3a10a50b93..b2bdc1e6bd 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1115,6 +1115,48 @@ async def test_expireat_unixtime(self, r: redis.Redis): assert await r.expireat("a", expire_at_seconds) assert 0 < await r.ttl("a") <= 61 + @skip_if_server_version_lt("8.3.224") + async def test_digest_nonexistent_returns_none(self, r): + assert await r.digest("no:such:key") is None + + @skip_if_server_version_lt("8.3.224") + async def test_digest_wrong_type_raises(self, r): + await r.lpush("alist", "x") + with pytest.raises(Exception): # or redis.exceptions.ResponseError + await r.digest("alist") + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize( + "value", [b"", b"abc", b"The quick brown fox jumps over the lazy dog"] + ) + async def test_digest_response_when_available(self, r, value): + key = "k:digest" + await r.delete(key) + await r.set(key, value) + + res = await r.digest(key) + # got is str if decode_responses=True; ensure bytes->str for comparison + if isinstance(res, bytes): + res = res.decode() + assert res is not None + assert all(c in "0123456789abcdefABCDEF" for c in res) + + assert len(res) == 16 + + @skip_if_server_version_lt("8.3.224") + async def test_pipeline_digest(self, r): + k1, k2 = "k:d1", "k:d2" + await r.mset({k1: b"A", k2: b"B"}) + p = r.pipeline() + p.digest(k1) + p.digest(k2) + out = await p.execute() + assert len(out) == 2 + for d in out: + if isinstance(d, bytes): + d = d.decode() + assert d is None or len(d) == 16 + async def test_get_and_set(self, r: redis.Redis): # get and set can't be tested independently of each other assert await r.get("a") is None diff --git a/tests/test_commands.py b/tests/test_commands.py index 4925329a21..f322b31f72 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1666,6 +1666,48 @@ def test_expireat_option_lt(self, r): expire_at = redis_server_time(r) + datetime.timedelta(minutes=1) assert r.expireat("key", expire_at, lt=True) is True + @skip_if_server_version_lt("8.3.224") + def test_digest_nonexistent_returns_none(self, r): + assert r.digest("no:such:key") is None + + @skip_if_server_version_lt("8.3.224") + def test_digest_wrong_type_raises(self, r): + r.lpush("alist", "x") + with pytest.raises(Exception): # or redis.exceptions.ResponseError + r.digest("alist") + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize( + "value", [b"", b"abc", b"The quick brown fox jumps over the lazy dog"] + ) + def test_digest_response_when_available(self, r, value): + key = "k:digest" + r.delete(key) + r.set(key, value) + + res = r.digest(key) + # got is str if decode_responses=True; ensure bytes->str for comparison + if isinstance(res, bytes): + res = res.decode() + assert res is not None + assert all(c in "0123456789abcdefABCDEF" for c in res) + + assert len(res) == 16 + + @skip_if_server_version_lt("8.3.224") + def test_pipeline_digest(self, r): + k1, k2 = "k:d1", "k:d2" + r.mset({k1: b"A", k2: b"B"}) + p = r.pipeline() + p.digest(k1) + p.digest(k2) + out = p.execute() + assert len(out) == 2 + for d in out: + if isinstance(d, bytes): + d = d.decode() + assert d is None or len(d) == 16 + def test_get_and_set(self, r): # get and set can't be tested independently of each other assert r.get("a") is None From e0db616050530292222c9799110218e5d6f57358 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 6 Nov 2025 14:53:07 +0200 Subject: [PATCH 2/5] Add DELEX command support with tests --- redis/commands/core.py | 41 +++++++++++ tests/test_asyncio/test_commands.py | 108 ++++++++++++++++++++++++++++ tests/test_commands.py | 108 ++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) diff --git a/redis/commands/core.py b/redis/commands/core.py index 5d3763b9a6..d0e0fc5f41 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -1729,6 +1729,47 @@ def delete(self, *names: KeyT) -> ResponseT: def __delitem__(self, name: KeyT): self.delete(name) + def delex( + self, + name: KeyT, + ifeq: Optional[EncodableT] = None, + ifne: Optional[EncodableT] = None, + ifdeq: Optional[str] = None, # hex digest + ifdne: Optional[str] = None, # hex digest + ) -> int: + """ + Conditionally removes the specified key. + + ifeq match-value - Delete the key only if its value is equal to match-value + ifne match-value - Delete the key only if its value is not equal to match-value + ifdeq match-digest - Delete the key only if the digest of its value is equal to match-digest + ifdne match-digest - Delete the key only if the digest of its value is not equal to match-digest + + Returns: + int: 1 if the key was deleted, 0 otherwise. + Raises: + redis.exceptions.ResponseError: if key exists but is not a string + and a condition is specified. + ValueError: if more than one condition is provided. + + For more information, see https://redis.io/commands/delex + """ + conds = [x is not None for x in (ifeq, ifne, ifdeq, ifdne)] + if sum(conds) > 1: + raise ValueError("Only one of IFEQ/IFNE/IFDEQ/IFDNE may be specified") + + pieces = ["DELEX", name] + if ifeq is not None: + pieces += ["IFEQ", ifeq] + elif ifne is not None: + pieces += ["IFNE", ifne] + elif ifdeq is not None: + pieces += ["IFDEQ", ifdeq] + elif ifdne is not None: + pieces += ["IFDNE", ifdne] + + return self.execute_command(*pieces) + def dump(self, name: KeyT) -> ResponseT: """ Return a serialized version of the value stored at the specified key. diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index b2bdc1e6bd..118c9c4a95 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1032,6 +1032,114 @@ async def test_delitem(self, r: redis.Redis): await r.delete("a") assert await r.get("a") is None + def _ensure_str(self, x): + return x.decode("ascii") if isinstance(x, (bytes, bytearray)) else x + + async def _server_xxh3_digest(self, r, key): + """ + Get the server-computed XXH3 hex digest for the key's value. + Requires the DIGEST command implemented on the server. + """ + d = await r.execute_command("DIGEST", key) + return None if d is None else self._ensure_str(d).lower() + + @skip_if_server_version_lt("8.3.224") + async def test_delex_nonexistent(self, r): + await r.delete("nope") + assert await r.delex("nope") == 0 + + @skip_if_server_version_lt("8.3.224") + async def test_delex_unconditional_delete_string(self, r): + await r.set("k", b"v") + assert await r.exists("k") == 1 + assert await r.delex("k") == 1 + assert await r.exists("k") == 0 + + @skip_if_server_version_lt("8.3.224") + async def test_delex_unconditional_delete_nonstring_allowed(self, r): + # Spec: error happens only when a condition is specified on a non-string key. + await r.lpush("lst", "a") + assert await r.delex("lst") == 1 + assert await r.exists("lst") == 0 + + await r.lpush("lst", "a") + + with pytest.raises(redis.ResponseError): + await r.delex("lst", ifeq=b"a") + assert await r.exists("lst") == 1 + + @skip_if_server_version_lt("8.3.224") + async def test_delex_ifeq(self, r): + await r.set("k", b"abc") + assert await r.delex("k", ifeq=b"abc") == 1 # matches → deleted + assert await r.exists("k") == 0 + + await r.set("k", b"abc") + assert await r.delex("k", ifeq=b"zzz") == 0 # not match → not deleted + assert await r.get("k") == b"abc" # still there + + @skip_if_server_version_lt("8.3.224") + async def test_delex_ifne(self, r): + await r.set("k2", b"abc") + assert await r.delex("k2", ifne=b"zzz") == 1 # different → deleted + assert await r.exists("k2") == 0 + + await r.set("k2", b"abc") + assert await r.delex("k2", ifne=b"abc") == 0 # equal → not deleted + assert await r.get("k2") == b"abc" + + @skip_if_server_version_lt("8.3.224") + async def test_delex_with_conditionon_nonstring_values(self, r): + await r.lpush("nk", "x") + with pytest.raises(redis.ResponseError): + await r.delex("nk", ifeq=b"x") + with pytest.raises(redis.ResponseError): + await r.delex("nk", ifne=b"x") + with pytest.raises(redis.ResponseError): + await r.delex("nk", ifdeq="deadbeef") + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"]) + async def test_delex_ifdeq_and_ifdne(self, r, val): + await r.set("h", val) + d = await self._server_xxh3_digest(r, "h") + assert d is not None + + # IFDEQ should delete with exact digest + await r.set("h", val) + assert await r.delex("h", ifdeq=d) == 1 + assert await r.exists("h") == 0 + + # IFDNE should NOT delete when digest matches + await r.set("h", val) + assert await r.delex("h", ifdne=d) == 0 + assert await r.get("h") == val + + # IFDNE should delete when digest doesn't match + await r.set("h", val) + wrong = "0" * len(d) + if wrong == d: + wrong = "f" * len(d) + assert await r.delex("h", ifdne=wrong) == 1 + assert await r.exists("h") == 0 + + @skip_if_server_version_lt("8.3.224") + async def test_delex_pipeline(self, r): + await r.mset({"p1": b"A", "p2": b"B"}) + p = r.pipeline() + p.delex("p1", ifeq=b"A") + p.delex("p2", ifne=b"B") # false → 0 + p.delex("nope") # nonexistent → 0 + out = await p.execute() + assert out == [1, 0, 0] + + @skip_if_server_version_lt("8.3.224") + async def test_delex_mutual_exclusion_client_side(self, r): + with pytest.raises(ValueError): + await r.delex("k", ifeq=b"A", ifne=b"B") + with pytest.raises(ValueError): + await r.delex("k", ifdeq="aa", ifdne="bb") + @skip_if_server_version_lt("4.0.0") async def test_unlink(self, r: redis.Redis): assert await r.unlink("a") == 0 diff --git a/tests/test_commands.py b/tests/test_commands.py index f322b31f72..dcf4022619 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1501,6 +1501,114 @@ def test_delitem(self, r): del r["a"] assert r.get("a") is None + def _ensure_str(self, x): + return x.decode("ascii") if isinstance(x, (bytes, bytearray)) else x + + def _server_xxh3_digest(self, r, key): + """ + Get the server-computed XXH3 hex digest for the key's value. + Requires the DIGEST command implemented on the server. + """ + d = r.execute_command("DIGEST", key) + return None if d is None else self._ensure_str(d).lower() + + @skip_if_server_version_lt("8.3.224") + def test_delex_nonexistent(self, r): + r.delete("nope") + assert r.delex("nope") == 0 + + @skip_if_server_version_lt("8.3.224") + def test_delex_unconditional_delete_string(self, r): + r.set("k", b"v") + assert r.exists("k") == 1 + assert r.delex("k") == 1 + assert r.exists("k") == 0 + + @skip_if_server_version_lt("8.3.224") + def test_delex_unconditional_delete_nonstring_allowed(self, r): + # Spec: error happens only when a condition is specified on a non-string key. + r.lpush("lst", "a") + assert r.delex("lst") == 1 + assert r.exists("lst") == 0 + + r.lpush("lst", "a") + + with pytest.raises(redis.ResponseError): + r.delex("lst", ifeq=b"a") + assert r.exists("lst") == 1 + + @skip_if_server_version_lt("8.3.224") + def test_delex_ifeq(self, r): + r.set("k", b"abc") + assert r.delex("k", ifeq=b"abc") == 1 # matches → deleted + assert r.exists("k") == 0 + + r.set("k", b"abc") + assert r.delex("k", ifeq=b"zzz") == 0 # not match → not deleted + assert r.get("k") == b"abc" # still there + + @skip_if_server_version_lt("8.3.224") + def test_delex_ifne(self, r): + r.set("k2", b"abc") + assert r.delex("k2", ifne=b"zzz") == 1 # different → deleted + assert r.exists("k2") == 0 + + r.set("k2", b"abc") + assert r.delex("k2", ifne=b"abc") == 0 # equal → not deleted + assert r.get("k2") == b"abc" + + @skip_if_server_version_lt("8.3.224") + def test_delex_with_conditionon_nonstring_values(self, r): + r.lpush("nk", "x") + with pytest.raises(redis.ResponseError): + r.delex("nk", ifeq=b"x") + with pytest.raises(redis.ResponseError): + r.delex("nk", ifne=b"x") + with pytest.raises(redis.ResponseError): + r.delex("nk", ifdeq="deadbeef") + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"]) + def test_delex_ifdeq_and_ifdne(self, r, val): + r.set("h", val) + d = self._server_xxh3_digest(r, "h") + assert d is not None + + # IFDEQ should delete with exact digest + r.set("h", val) + assert r.delex("h", ifdeq=d) == 1 + assert r.exists("h") == 0 + + # IFDNE should NOT delete when digest matches + r.set("h", val) + assert r.delex("h", ifdne=d) == 0 + assert r.get("h") == val + + # IFDNE should delete when digest doesn't match + r.set("h", val) + wrong = "0" * len(d) + if wrong == d: + wrong = "f" * len(d) + assert r.delex("h", ifdne=wrong) == 1 + assert r.exists("h") == 0 + + @skip_if_server_version_lt("8.3.224") + def test_delex_pipeline(self, r): + r.mset({"p1": b"A", "p2": b"B"}) + p = r.pipeline() + p.delex("p1", ifeq=b"A") + p.delex("p2", ifne=b"B") # false → 0 + p.delex("nope") # nonexistent → 0 + out = p.execute() + assert out == [1, 0, 0] + + @skip_if_server_version_lt("8.3.224") + def test_delex_mutual_exclusion_client_side(self, r): + with pytest.raises(ValueError): + r.delex("k", ifeq=b"A", ifne=b"B") + with pytest.raises(ValueError): + r.delex("k", ifdeq="aa", ifdne="bb") + @skip_if_server_version_lt("4.0.0") def test_unlink(self, r): assert r.unlink("a") == 0 From 45f506ba09d21797f0676a6f0c19ed1de2d6b9a7 Mon Sep 17 00:00:00 2001 From: Petya Slavova Date: Thu, 6 Nov 2025 18:38:06 +0200 Subject: [PATCH 3/5] Adding changes related to SET command --- redis/commands/core.py | 78 ++++++++++++++------ redis/commands/helpers.py | 21 +++++- tests/test_asyncio/test_commands.py | 106 ++++++++++++++++++++++++++-- tests/test_commands.py | 102 ++++++++++++++++++++++++-- 4 files changed, 275 insertions(+), 32 deletions(-) diff --git a/redis/commands/core.py b/redis/commands/core.py index d0e0fc5f41..3f6b329c15 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -50,7 +50,7 @@ extract_expire_flags, ) -from .helpers import list_or_args +from .helpers import at_most_one_value_set, list_or_args if TYPE_CHECKING: import redis.asyncio.client @@ -1732,8 +1732,8 @@ def __delitem__(self, name: KeyT): def delex( self, name: KeyT, - ifeq: Optional[EncodableT] = None, - ifne: Optional[EncodableT] = None, + ifeq: Optional[Union[bytes, str]] = None, + ifne: Optional[Union[bytes, str]] = None, ifdeq: Optional[str] = None, # hex digest ifdne: Optional[str] = None, # hex digest ) -> int: @@ -1752,6 +1752,8 @@ def delex( and a condition is specified. ValueError: if more than one condition is provided. + + Requires Redis 8.4 or greater. For more information, see https://redis.io/commands/delex """ conds = [x is not None for x in (ifeq, ifne, ifdeq, ifdne)] @@ -1886,6 +1888,8 @@ def digest(self, name: KeyT) -> Optional[str]: Raises: - ResponseError if key exists but is not a string + + Requires Redis 8.4 or greater. For more information, see https://redis.io/commands/digest """ # Bulk string response is already handled (bytes/str based on decode_responses) @@ -1939,8 +1943,7 @@ def getex( For more information, see https://redis.io/commands/getex """ - opset = {ex, px, exat, pxat} - if len(opset) > 2 or len(opset) > 1 and persist: + if not at_most_one_value_set((ex, px, exat, pxat, persist)): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive." @@ -2128,8 +2131,7 @@ def msetex( Available since Redis 8.4 For more information, see https://redis.io/commands/msetex """ - opset = {ex, px, exat, pxat} - if len(opset) > 2 or len(opset) > 1 and keepttl: + if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." @@ -2395,6 +2397,10 @@ def set( get: bool = False, exat: Optional[AbsExpiryT] = None, pxat: Optional[AbsExpiryT] = None, + ifeq: Optional[Union[bytes, str]] = None, + ifne: Optional[Union[bytes, str]] = None, + ifdeq: Optional[str] = None, # hex digest of current value + ifdne: Optional[str] = None, # hex digest of current value ) -> ResponseT: """ Set the value at key ``name`` to ``value`` @@ -2422,35 +2428,67 @@ def set( ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds, specified in unix time. + ``ifeq`` set the value at key ``name`` to ``value`` only if the current + value exactly matches the argument. + If key doesn’t exist - it won’t be created. + (Requires Redis 8.4 or greater) + + ``ifne`` set the value at key ``name`` to ``value`` only if the current + value does not exactly match the argument. + If key doesn’t exist - it will be created. + (Requires Redis 8.4 or greater) + + ``ifdeq`` set the value at key ``name`` to ``value`` only if the current + value XXH3 hex digest exactly matches the argument. + If key doesn’t exist - it won’t be created. + (Requires Redis 8.4 or greater) + + ``ifdne`` set the value at key ``name`` to ``value`` only if the current + value XXH3 hex digest does not exactly match the argument. + If key doesn’t exist - it will be created. + (Requires Redis 8.4 or greater) + For more information, see https://redis.io/commands/set """ - opset = {ex, px, exat, pxat} - if len(opset) > 2 or len(opset) > 1 and keepttl: + + if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." ) - if nx and xx: - raise DataError("``nx`` and ``xx`` are mutually exclusive.") + # Enforce mutual exclusivity among all conditional switches. + if not at_most_one_value_set((nx, xx, ifeq, ifne, ifdeq, ifdne)): + raise DataError( + "``nx``, ``xx``, ``ifeq``, ``ifne``, ``ifdeq``, ``ifdne`` are mutually exclusive." + ) pieces: list[EncodableT] = [name, value] options = {} - pieces.extend(extract_expire_flags(ex, px, exat, pxat)) - - if keepttl: - pieces.append("KEEPTTL") - + # Conditional modifier (exactly one at most) if nx: pieces.append("NX") - if xx: + elif xx: pieces.append("XX") + elif ifeq is not None: + pieces.extend(("IFEQ", ifeq)) + elif ifne is not None: + pieces.extend(("IFNE", ifne)) + elif ifdeq is not None: + pieces.extend(("IFDEQ", ifdeq)) + elif ifdne is not None: + pieces.extend(("IFDNE", ifdne)) if get: pieces.append("GET") options["get"] = True + pieces.extend(extract_expire_flags(ex, px, exat, pxat)) + + if keepttl: + pieces.append("KEEPTTL") + return self.execute_command("SET", *pieces, **options) def __setitem__(self, name: KeyT, value: EncodableT): @@ -5257,8 +5295,7 @@ def hgetex( if not keys: raise DataError("'hgetex' should have at least one key provided") - opset = {ex, px, exat, pxat} - if len(opset) > 2 or len(opset) > 1 and persist: + if not at_most_one_value_set((ex, px, exat, pxat, persist)): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``persist`` are mutually exclusive." @@ -5403,8 +5440,7 @@ def hsetex( "'items' must contain a list of key/value pairs." ) - opset = {ex, px, exat, pxat} - if len(opset) > 2 or len(opset) > 1 and keepttl: + if not at_most_one_value_set((ex, px, exat, pxat, keepttl)): raise DataError( "``ex``, ``px``, ``exat``, ``pxat``, " "and ``keepttl`` are mutually exclusive." diff --git a/redis/commands/helpers.py b/redis/commands/helpers.py index 8fd2b9527e..4a57591f04 100644 --- a/redis/commands/helpers.py +++ b/redis/commands/helpers.py @@ -1,7 +1,7 @@ import copy import random import string -from typing import List, Tuple +from typing import Any, Iterable, List, Tuple import redis from redis.typing import KeysT, KeyT @@ -96,3 +96,22 @@ def get_protocol_version(client): return client.connection_pool.connection_kwargs.get("protocol") elif isinstance(client, redis.cluster.AbstractRedisCluster): return client.nodes_manager.connection_kwargs.get("protocol") + + +def at_most_one_value_set(iterable: Iterable[Any]): + """ + Checks that at most one of the values in the iterable is truthy. + + Args: + iterable: An iterable of values to check. + + Returns: + True if at most one value is truthy, False otherwise. + + Raises: + Might raise an error if the values in iterable are not boolean-compatible. + For example if the type of the values implement + __len__ or __bool__ methods and they raise an error. + """ + values = (bool(x) for x in iterable) + return sum(values) <= 1 diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 118c9c4a95..47d8893743 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -11,7 +11,7 @@ import pytest import pytest_asyncio -from redis import RedisClusterException, ResponseError +from redis import DataError, RedisClusterException, ResponseError import redis from redis import exceptions from redis._parsers.helpers import ( @@ -1125,10 +1125,10 @@ async def test_delex_ifdeq_and_ifdne(self, r, val): @skip_if_server_version_lt("8.3.224") async def test_delex_pipeline(self, r): - await r.mset({"p1": b"A", "p2": b"B"}) + await r.mset({"p1{45}": b"A", "p2{45}": b"B"}) p = r.pipeline() - p.delex("p1", ifeq=b"A") - p.delex("p2", ifne=b"B") # false → 0 + p.delex("p1{45}", ifeq=b"A") + p.delex("p2{45}", ifne=b"B") # false → 0 p.delex("nope") # nonexistent → 0 out = await p.execute() assert out == [1, 0, 0] @@ -1253,7 +1253,7 @@ async def test_digest_response_when_available(self, r, value): @skip_if_server_version_lt("8.3.224") async def test_pipeline_digest(self, r): - k1, k2 = "k:d1", "k:d2" + k1, k2 = "k:d1{42}", "k:d2{42}" await r.mset({k1: b"A", k2: b"B"}) p = r.pipeline() p.digest(k1) @@ -1849,6 +1849,102 @@ async def test_set_keepttl(self, r: redis.Redis): assert await r.get("a") == b"2" assert 0 < await r.ttl("a") <= 10 + @skip_if_server_version_lt("8.3.224") + async def test_set_ifeq_true_sets_and_returns_true(self, r): + await r.delete("k") + await r.set("k", b"foo") + assert await r.set("k", b"bar", ifeq=b"foo") is True + assert await r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + async def test_set_ifeq_false_does_not_set_returns_none(self, r): + await r.delete("k") + await r.set("k", b"foo") + assert await r.set("k", b"bar", ifeq=b"nope") is None + assert await r.get("k") == b"foo" + + @skip_if_server_version_lt("8.3.224") + async def test_set_ifne_true_sets(self, r): + await r.delete("k") + await r.set("k", b"foo") + assert await r.set("k", b"bar", ifne=b"zzz") is True + assert await r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + async def test_set_ifne_false_does_not_set(self, r): + await r.delete("k") + await r.set("k", b"foo") + assert await r.set("k", b"bar", ifne=b"foo") is None + assert await r.get("k") == b"foo" + + @skip_if_server_version_lt("8.3.224") + async def test_set_ifeq_when_key_missing_does_not_create(self, r): + await r.delete("k") + assert await r.set("k", b"bar", ifeq=b"foo") is None + assert await r.exists("k") == 0 + + @skip_if_server_version_lt("8.3.224") + async def test_set_ifne_when_key_missing_creates(self, r): + await r.delete("k") + assert await r.set("k", b"bar", ifne=b"foo") is True + assert await r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"]) + async def test_set_ifdeq_and_ifdne(self, r, val): + await r.delete("k") + await r.set("k", val) + d = await self._server_xxh3_digest(r, "k") + assert d is not None + + # IFDEQ must match to set; if key missing => won't create + assert await r.set("k", b"X", ifdeq=d) is True + assert await r.get("k") == b"X" + + await r.delete("k") + # key missing + IFDEQ => not created + assert await r.set("k", b"Y", ifdeq=d) is None + assert await r.exists("k") == 0 + + # IFDNE: create when missing, and set when digest differs + assert await r.set("k", b"bar", ifdne=d) is True + prev_d = await self._server_xxh3_digest(r, "k") + assert prev_d is not None + # If digest equal → do not set + assert await r.set("k", b"zzz", ifdne=prev_d) is None + assert await r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + async def test_set_with_get_returns_previous_value(self, r): + await r.delete("k") + # when key didn’t exist → returns None, and key is created if condition allows it + prev = await r.set("k", b"v1", get=True, ifne=b"any") # IFNE on missing creates + assert prev is None + # subsequent GET returns previous value, regardless of whether set occurs + prev2 = await r.set( + "k", b"v2", get=True, ifeq=b"v1" + ) # matches → set; returns "v1" + assert prev2 == b"v1" + prev3 = await r.set( + "k", b"v3", get=True, ifeq=b"no" + ) # no set; returns previous "v2" + assert prev3 == b"v2" + assert await r.get("k") == b"v2" + + @skip_if_server_version_lt("8.3.224") + async def test_set_mutual_exclusion_client_side(self, r): + await r.delete("k") + with pytest.raises(DataError): + await r.set("k", b"v", nx=True, ifeq=b"x") + with pytest.raises(DataError): + await r.set("k", b"v", ifdeq="aa", ifdne="bb") + with pytest.raises(DataError): + await r.set("k", b"v", ex=1, px=1) + with pytest.raises(DataError): + await r.set("k", b"v", exat=1, pxat=1) + with pytest.raises(DataError): + await r.set("k", b"v", ex=1, exat=1) + async def test_setex(self, r: redis.Redis): assert await r.setex("a", 60, "1") assert await r.get("a") == b"1" diff --git a/tests/test_commands.py b/tests/test_commands.py index dcf4022619..d10e45f88f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from redis import RedisClusterException, ResponseError +from redis import DataError, RedisClusterException, ResponseError import redis from redis import exceptions from redis._parsers.helpers import ( @@ -1594,10 +1594,10 @@ def test_delex_ifdeq_and_ifdne(self, r, val): @skip_if_server_version_lt("8.3.224") def test_delex_pipeline(self, r): - r.mset({"p1": b"A", "p2": b"B"}) + r.mset({"p1{45}": b"A", "p2{45}": b"B"}) p = r.pipeline() - p.delex("p1", ifeq=b"A") - p.delex("p2", ifne=b"B") # false → 0 + p.delex("p1{45}", ifeq=b"A") + p.delex("p2{45}", ifne=b"B") # false → 0 p.delex("nope") # nonexistent → 0 out = p.execute() assert out == [1, 0, 0] @@ -1804,7 +1804,7 @@ def test_digest_response_when_available(self, r, value): @skip_if_server_version_lt("8.3.224") def test_pipeline_digest(self, r): - k1, k2 = "k:d1", "k:d2" + k1, k2 = "k:d1{42}", "k:d2{42}" r.mset({k1: b"A", k2: b"B"}) p = r.pipeline() p.digest(k1) @@ -2527,6 +2527,98 @@ def test_set_keepttl(self, r): assert r.get("a") == b"2" assert 0 < r.ttl("a") <= 10 + @skip_if_server_version_lt("8.3.224") + def test_set_ifeq_true_sets_and_returns_true(self, r): + r.delete("k") + r.set("k", b"foo") + assert r.set("k", b"bar", ifeq=b"foo") is True + assert r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + def test_set_ifeq_false_does_not_set_returns_none(self, r): + r.delete("k") + r.set("k", b"foo") + assert r.set("k", b"bar", ifeq=b"nope") is None + assert r.get("k") == b"foo" + + @skip_if_server_version_lt("8.3.224") + def test_set_ifne_true_sets(self, r): + r.delete("k") + r.set("k", b"foo") + assert r.set("k", b"bar", ifne=b"zzz") is True + assert r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + def test_set_ifne_false_does_not_set(self, r): + r.delete("k") + r.set("k", b"foo") + assert r.set("k", b"bar", ifne=b"foo") is None + assert r.get("k") == b"foo" + + @skip_if_server_version_lt("8.3.224") + def test_set_ifeq_when_key_missing_does_not_create(self, r): + r.delete("k") + assert r.set("k", b"bar", ifeq=b"foo") is None + assert r.exists("k") == 0 + + @skip_if_server_version_lt("8.3.224") + def test_set_ifne_when_key_missing_creates(self, r): + r.delete("k") + assert r.set("k", b"bar", ifne=b"foo") is True + assert r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + @pytest.mark.parametrize("val", [b"", b"abc", b"The quick brown fox"]) + def test_set_ifdeq_and_ifdne(self, r, val): + r.delete("k") + r.set("k", val) + d = self._server_xxh3_digest(r, "k") + assert d is not None + + # IFDEQ must match to set; if key missing => won't create + assert r.set("k", b"X", ifdeq=d) is True + assert r.get("k") == b"X" + + r.delete("k") + # key missing + IFDEQ => not created + assert r.set("k", b"Y", ifdeq=d) is None + assert r.exists("k") == 0 + + # IFDNE: create when missing, and set when digest differs + assert r.set("k", b"bar", ifdne=d) is True + prev_d = self._server_xxh3_digest(r, "k") + assert prev_d is not None + # If digest equal → do not set + assert r.set("k", b"zzz", ifdne=prev_d) is None + assert r.get("k") == b"bar" + + @skip_if_server_version_lt("8.3.224") + def test_set_with_get_returns_previous_value(self, r): + r.delete("k") + # when key didn’t exist → returns None, and key is created if condition allows it + prev = r.set("k", b"v1", get=True, ifne=b"any") # IFNE on missing creates + assert prev is None + # subsequent GET returns previous value, regardless of whether set occurs + prev2 = r.set("k", b"v2", get=True, ifeq=b"v1") # matches → set; returns "v1" + assert prev2 == b"v1" + prev3 = r.set("k", b"v3", get=True, ifeq=b"no") # no set; returns previous "v2" + assert prev3 == b"v2" + assert r.get("k") == b"v2" + + @skip_if_server_version_lt("8.3.224") + def test_set_mutual_exclusion_client_side(self, r): + r.delete("k") + with pytest.raises(DataError): + r.set("k", b"v", nx=True, ifeq=b"x") + with pytest.raises(DataError): + r.set("k", b"v", ifdeq="aa", ifdne="bb") + with pytest.raises(DataError): + r.set("k", b"v", ex=1, px=1) + with pytest.raises(DataError): + r.set("k", b"v", exat=1, pxat=1) + with pytest.raises(DataError): + r.set("k", b"v", ex=1, exat=1) + @skip_if_server_version_lt("6.2.0") def test_set_get(self, r): assert r.set("a", "True", get=True) is None From acb4d426780279ea9651b5ef631e35391bf448b4 Mon Sep 17 00:00:00 2001 From: petyaslavova Date: Thu, 6 Nov 2025 20:27:40 +0200 Subject: [PATCH 4/5] Update tests/test_asyncio/test_commands.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_asyncio/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index 47d8893743..6e3d88d7bb 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -1089,7 +1089,7 @@ async def test_delex_ifne(self, r): assert await r.get("k2") == b"abc" @skip_if_server_version_lt("8.3.224") - async def test_delex_with_conditionon_nonstring_values(self, r): + async def test_delex_with_condition_on_nonstring_values(self, r): await r.lpush("nk", "x") with pytest.raises(redis.ResponseError): await r.delex("nk", ifeq=b"x") From 75998877722ef8b78917ed84eeaadba4a8619f35 Mon Sep 17 00:00:00 2001 From: petyaslavova Date: Thu, 6 Nov 2025 20:27:49 +0200 Subject: [PATCH 5/5] Update tests/test_commands.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index d10e45f88f..aab80aede3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1558,7 +1558,7 @@ def test_delex_ifne(self, r): assert r.get("k2") == b"abc" @skip_if_server_version_lt("8.3.224") - def test_delex_with_conditionon_nonstring_values(self, r): + def test_delex_with_condition_on_nonstring_values(self, r): r.lpush("nk", "x") with pytest.raises(redis.ResponseError): r.delex("nk", ifeq=b"x")