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..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 @@ -1729,6 +1729,49 @@ def delete(self, *names: KeyT) -> ResponseT: def __delitem__(self, name: KeyT): self.delete(name) + def delex( + self, + name: KeyT, + 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: + """ + 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. + + + 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)] + 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. @@ -1835,6 +1878,23 @@ 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 + + + 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) + 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 @@ -1883,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." @@ -2072,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." @@ -2339,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`` @@ -2366,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): @@ -5201,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." @@ -5347,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 3a10a50b93..6e3d88d7bb 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 ( @@ -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_condition_on_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{45}": b"A", "p2{45}": b"B"}) + p = r.pipeline() + 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] + + @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 @@ -1115,6 +1223,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{42}", "k:d2{42}" + 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 @@ -1699,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 4925329a21..aab80aede3 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 ( @@ -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_condition_on_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{45}": b"A", "p2{45}": b"B"}) + p = r.pipeline() + 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] + + @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 @@ -1666,6 +1774,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{42}", "k:d2{42}" + 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 @@ -2377,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