Skip to content

Commit e6fb505

Browse files
authored
Adding support for CAS/CAD commands. (#3837)
* Add DIGEST command support with tests * Add DELEX command support with tests * Adding changes related to SET command * Add experimental annotations for the CAS/CAD methods and new args
1 parent b74bc52 commit e6fb505

File tree

6 files changed

+721
-22
lines changed

6 files changed

+721
-22
lines changed

redis/commands/cluster.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"GEOPOS",
6262
"GEORADIUS",
6363
"GEORADIUSBYMEMBER",
64+
"DIGEST",
6465
"GET",
6566
"GETBIT",
6667
"GETRANGE",

redis/commands/core.py

Lines changed: 137 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@
4747
)
4848
from redis.utils import (
4949
deprecated_function,
50+
experimental_args,
51+
experimental_method,
5052
extract_expire_flags,
5153
)
5254

53-
from .helpers import list_or_args
55+
from .helpers import at_most_one_value_set, list_or_args
5456

5557
if TYPE_CHECKING:
5658
import redis.asyncio.client
@@ -1729,6 +1731,57 @@ def delete(self, *names: KeyT) -> ResponseT:
17291731
def __delitem__(self, name: KeyT):
17301732
self.delete(name)
17311733

1734+
@experimental_method()
1735+
def delex(
1736+
self,
1737+
name: KeyT,
1738+
ifeq: Optional[Union[bytes, str]] = None,
1739+
ifne: Optional[Union[bytes, str]] = None,
1740+
ifdeq: Optional[str] = None, # hex digest
1741+
ifdne: Optional[str] = None, # hex digest
1742+
) -> int:
1743+
"""
1744+
Conditionally removes the specified key.
1745+
1746+
Warning:
1747+
**Experimental** since 7.1.
1748+
This API may change or be removed without notice.
1749+
The API may change based on feedback.
1750+
1751+
Arguments:
1752+
name: KeyT - the key to delete
1753+
ifeq match-valu: Optional[Union[bytes, str]] - Delete the key only if its value is equal to match-value
1754+
ifne match-value: Optional[Union[bytes, str]] - Delete the key only if its value is not equal to match-value
1755+
ifdeq match-digest: Optional[str] - Delete the key only if the digest of its value is equal to match-digest
1756+
ifdne match-digest: Optional[str] - Delete the key only if the digest of its value is not equal to match-digest
1757+
1758+
Returns:
1759+
int: 1 if the key was deleted, 0 otherwise.
1760+
Raises:
1761+
redis.exceptions.ResponseError: if key exists but is not a string
1762+
and a condition is specified.
1763+
ValueError: if more than one condition is provided.
1764+
1765+
1766+
Requires Redis 8.4 or greater.
1767+
For more information, see https://redis.io/commands/delex
1768+
"""
1769+
conds = [x is not None for x in (ifeq, ifne, ifdeq, ifdne)]
1770+
if sum(conds) > 1:
1771+
raise ValueError("Only one of IFEQ/IFNE/IFDEQ/IFDNE may be specified")
1772+
1773+
pieces = ["DELEX", name]
1774+
if ifeq is not None:
1775+
pieces += ["IFEQ", ifeq]
1776+
elif ifne is not None:
1777+
pieces += ["IFNE", ifne]
1778+
elif ifdeq is not None:
1779+
pieces += ["IFDEQ", ifdeq]
1780+
elif ifdne is not None:
1781+
pieces += ["IFDNE", ifdne]
1782+
1783+
return self.execute_command(*pieces)
1784+
17321785
def dump(self, name: KeyT) -> ResponseT:
17331786
"""
17341787
Return a serialized version of the value stored at the specified key.
@@ -1835,6 +1888,32 @@ def expiretime(self, key: str) -> int:
18351888
"""
18361889
return self.execute_command("EXPIRETIME", key)
18371890

1891+
@experimental_method()
1892+
def digest(self, name: KeyT) -> Optional[str]:
1893+
"""
1894+
Return the digest of the value stored at the specified key.
1895+
1896+
Warning:
1897+
**Experimental** since 7.1.
1898+
This API may change or be removed without notice.
1899+
The API may change based on feedback.
1900+
1901+
Arguments:
1902+
- name: KeyT - the key to get the digest of
1903+
1904+
Returns:
1905+
- None if the key does not exist
1906+
- (bulk string) the XXH3 digest of the value as a hex string
1907+
Raises:
1908+
- ResponseError if key exists but is not a string
1909+
1910+
1911+
Requires Redis 8.4 or greater.
1912+
For more information, see https://redis.io/commands/digest
1913+
"""
1914+
# Bulk string response is already handled (bytes/str based on decode_responses)
1915+
return self.execute_command("DIGEST", name)
1916+
18381917
def get(self, name: KeyT) -> ResponseT:
18391918
"""
18401919
Return the value at key ``name``, or None if the key doesn't exist
@@ -1883,8 +1962,7 @@ def getex(
18831962
18841963
For more information, see https://redis.io/commands/getex
18851964
"""
1886-
opset = {ex, px, exat, pxat}
1887-
if len(opset) > 2 or len(opset) > 1 and persist:
1965+
if not at_most_one_value_set((ex, px, exat, pxat, persist)):
18881966
raise DataError(
18891967
"``ex``, ``px``, ``exat``, ``pxat``, "
18901968
"and ``persist`` are mutually exclusive."
@@ -2072,8 +2150,7 @@ def msetex(
20722150
Available since Redis 8.4
20732151
For more information, see https://redis.io/commands/msetex
20742152
"""
2075-
opset = {ex, px, exat, pxat}
2076-
if len(opset) > 2 or len(opset) > 1 and keepttl:
2153+
if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):
20772154
raise DataError(
20782155
"``ex``, ``px``, ``exat``, ``pxat``, "
20792156
"and ``keepttl`` are mutually exclusive."
@@ -2327,6 +2404,7 @@ def restore(
23272404

23282405
return self.execute_command("RESTORE", *params)
23292406

2407+
@experimental_args(["ifeq", "ifne", "ifdeq", "ifdne"])
23302408
def set(
23312409
self,
23322410
name: KeyT,
@@ -2339,10 +2417,20 @@ def set(
23392417
get: bool = False,
23402418
exat: Optional[AbsExpiryT] = None,
23412419
pxat: Optional[AbsExpiryT] = None,
2420+
ifeq: Optional[Union[bytes, str]] = None,
2421+
ifne: Optional[Union[bytes, str]] = None,
2422+
ifdeq: Optional[str] = None, # hex digest of current value
2423+
ifdne: Optional[str] = None, # hex digest of current value
23422424
) -> ResponseT:
23432425
"""
23442426
Set the value at key ``name`` to ``value``
23452427
2428+
Warning:
2429+
**Experimental** since 7.1.
2430+
The usage of the arguments ``ifeq``, ``ifne``, ``ifdeq``, and ``ifdne``
2431+
is experimental. The API or returned results when those parameters are used
2432+
may change based on feedback.
2433+
23462434
``ex`` sets an expire flag on key ``name`` for ``ex`` seconds.
23472435
23482436
``px`` sets an expire flag on key ``name`` for ``px`` milliseconds.
@@ -2366,35 +2454,67 @@ def set(
23662454
``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds,
23672455
specified in unix time.
23682456
2457+
``ifeq`` set the value at key ``name`` to ``value`` only if the current
2458+
value exactly matches the argument.
2459+
If key doesn’t exist - it won’t be created.
2460+
(Requires Redis 8.4 or greater)
2461+
2462+
``ifne`` set the value at key ``name`` to ``value`` only if the current
2463+
value does not exactly match the argument.
2464+
If key doesn’t exist - it will be created.
2465+
(Requires Redis 8.4 or greater)
2466+
2467+
``ifdeq`` set the value at key ``name`` to ``value`` only if the current
2468+
value XXH3 hex digest exactly matches the argument.
2469+
If key doesn’t exist - it won’t be created.
2470+
(Requires Redis 8.4 or greater)
2471+
2472+
``ifdne`` set the value at key ``name`` to ``value`` only if the current
2473+
value XXH3 hex digest does not exactly match the argument.
2474+
If key doesn’t exist - it will be created.
2475+
(Requires Redis 8.4 or greater)
2476+
23692477
For more information, see https://redis.io/commands/set
23702478
"""
2371-
opset = {ex, px, exat, pxat}
2372-
if len(opset) > 2 or len(opset) > 1 and keepttl:
2479+
2480+
if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):
23732481
raise DataError(
23742482
"``ex``, ``px``, ``exat``, ``pxat``, "
23752483
"and ``keepttl`` are mutually exclusive."
23762484
)
23772485

2378-
if nx and xx:
2379-
raise DataError("``nx`` and ``xx`` are mutually exclusive.")
2486+
# Enforce mutual exclusivity among all conditional switches.
2487+
if not at_most_one_value_set((nx, xx, ifeq, ifne, ifdeq, ifdne)):
2488+
raise DataError(
2489+
"``nx``, ``xx``, ``ifeq``, ``ifne``, ``ifdeq``, ``ifdne`` are mutually exclusive."
2490+
)
23802491

23812492
pieces: list[EncodableT] = [name, value]
23822493
options = {}
23832494

2384-
pieces.extend(extract_expire_flags(ex, px, exat, pxat))
2385-
2386-
if keepttl:
2387-
pieces.append("KEEPTTL")
2388-
2495+
# Conditional modifier (exactly one at most)
23892496
if nx:
23902497
pieces.append("NX")
2391-
if xx:
2498+
elif xx:
23922499
pieces.append("XX")
2500+
elif ifeq is not None:
2501+
pieces.extend(("IFEQ", ifeq))
2502+
elif ifne is not None:
2503+
pieces.extend(("IFNE", ifne))
2504+
elif ifdeq is not None:
2505+
pieces.extend(("IFDEQ", ifdeq))
2506+
elif ifdne is not None:
2507+
pieces.extend(("IFDNE", ifdne))
23932508

23942509
if get:
23952510
pieces.append("GET")
23962511
options["get"] = True
23972512

2513+
pieces.extend(extract_expire_flags(ex, px, exat, pxat))
2514+
2515+
if keepttl:
2516+
pieces.append("KEEPTTL")
2517+
23982518
return self.execute_command("SET", *pieces, **options)
23992519

24002520
def __setitem__(self, name: KeyT, value: EncodableT):
@@ -5201,8 +5321,7 @@ def hgetex(
52015321
if not keys:
52025322
raise DataError("'hgetex' should have at least one key provided")
52035323

5204-
opset = {ex, px, exat, pxat}
5205-
if len(opset) > 2 or len(opset) > 1 and persist:
5324+
if not at_most_one_value_set((ex, px, exat, pxat, persist)):
52065325
raise DataError(
52075326
"``ex``, ``px``, ``exat``, ``pxat``, "
52085327
"and ``persist`` are mutually exclusive."
@@ -5347,8 +5466,7 @@ def hsetex(
53475466
"'items' must contain a list of key/value pairs."
53485467
)
53495468

5350-
opset = {ex, px, exat, pxat}
5351-
if len(opset) > 2 or len(opset) > 1 and keepttl:
5469+
if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):
53525470
raise DataError(
53535471
"``ex``, ``px``, ``exat``, ``pxat``, "
53545472
"and ``keepttl`` are mutually exclusive."

redis/commands/helpers.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import copy
22
import random
33
import string
4-
from typing import List, Tuple
4+
from typing import Any, Iterable, List, Tuple
55

66
import redis
77
from redis.typing import KeysT, KeyT
@@ -96,3 +96,22 @@ def get_protocol_version(client):
9696
return client.connection_pool.connection_kwargs.get("protocol")
9797
elif isinstance(client, redis.cluster.AbstractRedisCluster):
9898
return client.nodes_manager.connection_kwargs.get("protocol")
99+
100+
101+
def at_most_one_value_set(iterable: Iterable[Any]):
102+
"""
103+
Checks that at most one of the values in the iterable is truthy.
104+
105+
Args:
106+
iterable: An iterable of values to check.
107+
108+
Returns:
109+
True if at most one value is truthy, False otherwise.
110+
111+
Raises:
112+
Might raise an error if the values in iterable are not boolean-compatible.
113+
For example if the type of the values implement
114+
__len__ or __bool__ methods and they raise an error.
115+
"""
116+
values = (bool(x) for x in iterable)
117+
return sum(values) <= 1

redis/utils.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,3 +346,76 @@ def new_init(self, *args, **kwargs):
346346

347347
cls.__init__ = new_init
348348
return cls
349+
350+
351+
def warn_experimental(name, stacklevel=2):
352+
import warnings
353+
354+
msg = (
355+
f"Call to experimental method {name}. "
356+
"Be aware that the function arguments can "
357+
"change or be removed in future versions."
358+
)
359+
warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)
360+
361+
362+
def experimental_method() -> Callable[[C], C]:
363+
"""
364+
Decorator to mark a function as experimental.
365+
"""
366+
367+
def decorator(func: C) -> C:
368+
@wraps(func)
369+
def wrapper(*args, **kwargs):
370+
warn_experimental(func.__name__, stacklevel=2)
371+
return func(*args, **kwargs)
372+
373+
return wrapper
374+
375+
return decorator
376+
377+
378+
def warn_experimental_arg_usage(
379+
arg_name: Union[list, str],
380+
function_name: str,
381+
stacklevel: int = 2,
382+
):
383+
import warnings
384+
385+
msg = (
386+
f"Call to '{function_name}' method with experimental"
387+
f" usage of input argument/s '{arg_name}'."
388+
)
389+
warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)
390+
391+
392+
def experimental_args(
393+
args_to_warn: list = ["*"],
394+
) -> Callable[[C], C]:
395+
"""
396+
Decorator to mark specified args of a function as experimental.
397+
"""
398+
399+
def decorator(func: C) -> C:
400+
@wraps(func)
401+
def wrapper(*args, **kwargs):
402+
# Get function argument names
403+
arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]
404+
405+
provided_args = dict(zip(arg_names, args))
406+
provided_args.update(kwargs)
407+
408+
provided_args.pop("self", None)
409+
410+
if len(provided_args) == 0:
411+
return func(*args, **kwargs)
412+
413+
for arg in args_to_warn:
414+
if arg in provided_args:
415+
warn_experimental_arg_usage(arg, func.__name__, stacklevel=3)
416+
417+
return func(*args, **kwargs)
418+
419+
return wrapper
420+
421+
return decorator

0 commit comments

Comments
 (0)