diff --git a/CHANGELOG.md b/CHANGELOG.md index 517f223e97..cccb3c3026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * Python: Added SINTERCARD command ([#1511](https://github.com/aws/glide-for-redis/pull/1511)) * Python: Added SORT command ([#1439](https://github.com/aws/glide-for-redis/pull/1439)) * Node: Added OBJECT ENCODING command ([#1518](https://github.com/aws/glide-for-redis/pull/1518)) +* Python: Added LMOVE and BLMOVE commands ([#1536](https://github.com/aws/glide-for-redis/pull/1536)) ### Breaking Changes * Node: Update XREAD to return a Map of Map ([#1494](https://github.com/aws/glide-for-redis/pull/1494)) diff --git a/python/python/glide/__init__.py b/python/python/glide/__init__.py index ebd86eca1c..ae938e68d6 100644 --- a/python/python/glide/__init__.py +++ b/python/python/glide/__init__.py @@ -1,6 +1,6 @@ # Copyright GLIDE-for-Redis Project Contributors - SPDX Identifier: Apache-2.0 -from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.command_args import Limit, ListDirection, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -98,6 +98,7 @@ "json", "LexBoundary", "Limit", + "ListDirection", "RangeByIndex", "RangeByLex", "RangeByScore", diff --git a/python/python/glide/async_commands/command_args.py b/python/python/glide/async_commands/command_args.py index d308ca9ed7..b2e379bff8 100644 --- a/python/python/glide/async_commands/command_args.py +++ b/python/python/glide/async_commands/command_args.py @@ -43,3 +43,19 @@ class OrderBy(Enum): """ DESC: Sort in descending order. """ + + +class ListDirection(Enum): + """ + Enumeration representing element popping or adding direction for List commands. + """ + + LEFT = "LEFT" + """ + LEFT: Represents the option that elements should be popped from or added to the left side of a list. + """ + + RIGHT = "RIGHT" + """ + RIGHT: Represents the option that elements should be popped from or added to the right side of a list. + """ diff --git a/python/python/glide/async_commands/core.py b/python/python/glide/async_commands/core.py index 57d41fbb17..b1284c9b08 100644 --- a/python/python/glide/async_commands/core.py +++ b/python/python/glide/async_commands/core.py @@ -16,7 +16,7 @@ get_args, ) -from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.command_args import Limit, ListDirection, OrderBy from glide.async_commands.sorted_set import ( AggregationType, InfBound, @@ -1496,6 +1496,101 @@ async def linsert( ), ) + async def lmove( + self, + source: str, + destination: str, + where_from: ListDirection, + where_to: ListDirection, + ) -> Optional[str]: + """ + Atomically pops and removes the left/right-most element to the list stored at `source` + depending on `where_from`, and pushes the element at the first/last element of the list + stored at `destination` depending on `where_to`. + + When in cluster mode, both `source` and `destination` must map to the same hash slot. + + See https://valkey.io/commands/lmove/ for details. + + Args: + source (str): The key to the source list. + destination (str): The key to the destination list. + where_from (ListDirection): The direction to remove the element from (`ListDirection.LEFT` or `ListDirection.RIGHT`). + where_to (ListDirection): The direction to add the element to (`ListDirection.LEFT` or `ListDirection.RIGHT`). + + Returns: + Optional[str]: The popped element, or None if `source` does not exist. + + Examples: + >>> client.lpush("testKey1", ["two", "one"]) + >>> client.lpush("testKey2", ["four", "three"]) + >>> await client.lmove("testKey1", "testKey2", ListDirection.LEFT, ListDirection.LEFT) + "one" + >>> updated_array1 = await client.lrange("testKey1", 0, -1) + ["two"] + >>> await client.lrange("testKey2", 0, -1) + ["one", "three", "four"] + + Since: Redis version 6.2.0. + """ + return cast( + Optional[str], + await self._execute_command( + RequestType.LMove, + [source, destination, where_from.value, where_to.value], + ), + ) + + async def blmove( + self, + source: str, + destination: str, + where_from: ListDirection, + where_to: ListDirection, + timeout: float, + ) -> Optional[str]: + """ + Blocks the connection until it pops atomically and removes the left/right-most element to the + list stored at `source` depending on `where_from`, and pushes the element at the first/last element + of the list stored at `destination` depending on `where_to`. + `BLMOVE` is the blocking variant of `LMOVE`. + + Notes: + 1. When in cluster mode, both `source` and `destination` must map to the same hash slot. + 2. `BLMOVE` is a client blocking command, see https://github.com/aws/glide-for-redis/wiki/General-Concepts#blocking-commands for more details and best practices. + + See https://valkey.io/commands/blmove/ for details. + + Args: + source (str): The key to the source list. + destination (str): The key to the destination list. + where_from (ListDirection): The direction to remove the element from (`ListDirection.LEFT` or `ListDirection.RIGHT`). + where_to (ListDirection): The direction to add the element to (`ListDirection.LEFT` or `ListDirection.RIGHT`). + timeout (float): The number of seconds to wait for a blocking operation to complete. A value of `0` will block indefinitely. + + Returns: + Optional[str]: The popped element, or None if `source` does not exist or if the operation timed-out. + + Examples: + >>> await client.lpush("testKey1", ["two", "one"]) + >>> await client.lpush("testKey2", ["four", "three"]) + >>> await client.blmove("testKey1", "testKey2", ListDirection.LEFT, ListDirection.LEFT, 0.1) + "one" + >>> await client.lrange("testKey1", 0, -1) + ["two"] + >>> updated_array2 = await client.lrange("testKey2", 0, -1) + ["one", "three", "four"] + + Since: Redis version 6.2.0. + """ + return cast( + Optional[str], + await self._execute_command( + RequestType.BLMove, + [source, destination, where_from.value, where_to.value, str(timeout)], + ), + ) + async def sadd(self, key: str, members: List[str]) -> int: """ Add specified members to the set stored at `key`. diff --git a/python/python/glide/async_commands/transaction.py b/python/python/glide/async_commands/transaction.py index c33497e7ed..c94a4d0b35 100644 --- a/python/python/glide/async_commands/transaction.py +++ b/python/python/glide/async_commands/transaction.py @@ -3,7 +3,7 @@ import threading from typing import List, Mapping, Optional, Tuple, TypeVar, Union -from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.command_args import Limit, ListDirection, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -931,7 +931,7 @@ def linsert( """ Inserts `element` in the list at `key` either before or after the `pivot`. - See https://redis.io/commands/linsert/ for details. + See https://valkey.io/commands/linsert/ for details. Args: key (str): The key of the list. @@ -949,6 +949,68 @@ def linsert( RequestType.LInsert, [key, position.value, pivot, element] ) + def lmove( + self: TTransaction, + source: str, + destination: str, + where_from: ListDirection, + where_to: ListDirection, + ) -> TTransaction: + """ + Atomically pops and removes the left/right-most element to the list stored at `source` + depending on `where_from`, and pushes the element at the first/last element of the list + stored at `destination` depending on `where_to`. + + See https://valkey.io/commands/lmove/ for details. + + Args: + source (str): The key to the source list. + destination (str): The key to the destination list. + where_from (ListDirection): The direction to remove the element from (`ListDirection.LEFT` or `ListDirection.RIGHT`). + where_to (ListDirection): The direction to add the element to (`ListDirection.LEFT` or `ListDirection.RIGHT`). + + Command response: + Optional[str]: The popped element, or `None` if `source` does not exist. + + Since: Redis version 6.2.0. + """ + return self.append_command( + RequestType.LMove, [source, destination, where_from.value, where_to.value] + ) + + def blmove( + self: TTransaction, + source: str, + destination: str, + where_from: ListDirection, + where_to: ListDirection, + timeout: float, + ) -> TTransaction: + """ + Blocks the connection until it pops atomically and removes the left/right-most element to the + list stored at `source` depending on `where_from`, and pushes the element at the first/last element + of the list stored at `destination` depending on `where_to`. + `blmove` is the blocking variant of `lmove`. + + See https://valkey.io/commands/blmove/ for details. + + Args: + source (str): The key to the source list. + destination (str): The key to the destination list. + where_from (ListDirection): The direction to remove the element from (`ListDirection.LEFT` or `ListDirection.RIGHT`). + where_to (ListDirection): The direction to add the element to (`ListDirection.LEFT` or `ListDirection.RIGHT`). + timeout (float): The number of seconds to wait for a blocking operation to complete. A value of `0` will block indefinitely. + + Command response: + Optional[str]: The popped element, or `None` if `source` does not exist or if the operation timed-out. + + Since: Redis version 6.2.0. + """ + return self.append_command( + RequestType.BLMove, + [source, destination, where_from.value, where_to.value, str(timeout)], + ) + def sadd(self: TTransaction, key: str, members: List[str]) -> TTransaction: """ Add specified members to the set stored at `key`. diff --git a/python/python/tests/test_async_client.py b/python/python/tests/test_async_client.py index 48abd147fa..164acd4559 100644 --- a/python/python/tests/test_async_client.py +++ b/python/python/tests/test_async_client.py @@ -11,7 +11,7 @@ import pytest from glide import ClosingError, RequestError, Script -from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.command_args import Limit, ListDirection, OrderBy from glide.async_commands.core import ( ConditionalChange, ExpireOptions, @@ -1063,6 +1063,165 @@ async def test_linsert(self, redis_client: TRedisClient): with pytest.raises(RequestError): await redis_client.linsert(key2, InsertPosition.AFTER, "p", "e") + @pytest.mark.parametrize("cluster_mode", [True, False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_lmove(self, redis_client: TRedisClient): + key1 = "{SameSlot}" + get_random_string(10) + key2 = "{SameSlot}" + get_random_string(10) + + # Initialize the lists + assert await redis_client.lpush(key1, ["2", "1"]) == 2 + assert await redis_client.lpush(key2, ["4", "3"]) == 2 + + # Move from LEFT to LEFT + assert ( + await redis_client.lmove(key1, key2, ListDirection.LEFT, ListDirection.LEFT) + == "1" + ) + assert await redis_client.lrange(key1, 0, -1) == ["2"] + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4"] + + # Move from LEFT to RIGHT + assert ( + await redis_client.lmove( + key1, key2, ListDirection.LEFT, ListDirection.RIGHT + ) + == "2" + ) + assert await redis_client.lrange(key1, 0, -1) == [] + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4", "2"] + + # Move from RIGHT to LEFT - non-existing destination key + assert ( + await redis_client.lmove( + key2, key1, ListDirection.RIGHT, ListDirection.LEFT + ) + == "2" + ) + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4"] + assert await redis_client.lrange(key1, 0, -1) == ["2"] + + # Move from RIGHT to RIGHT + assert ( + await redis_client.lmove( + key2, key1, ListDirection.RIGHT, ListDirection.RIGHT + ) + == "4" + ) + assert await redis_client.lrange(key2, 0, -1) == ["1", "3"] + assert await redis_client.lrange(key1, 0, -1) == ["2", "4"] + + # Non-existing source key + assert ( + await redis_client.lmove( + "{SameSlot}non_existing_key", + key1, + ListDirection.LEFT, + ListDirection.LEFT, + ) + is None + ) + + # Non-list source key + key3 = get_random_string(10) + assert await redis_client.set(key3, "value") == OK + with pytest.raises(RequestError): + await redis_client.lmove(key3, key1, ListDirection.LEFT, ListDirection.LEFT) + + # Non-list destination key + with pytest.raises(RequestError): + await redis_client.lmove(key1, key3, ListDirection.LEFT, ListDirection.LEFT) + + @pytest.mark.parametrize("cluster_mode", [True, False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_blmove(self, redis_client: TRedisClient): + key1 = "{SameSlot}" + get_random_string(10) + key2 = "{SameSlot}" + get_random_string(10) + + # Initialize the lists + assert await redis_client.lpush(key1, ["2", "1"]) == 2 + assert await redis_client.lpush(key2, ["4", "3"]) == 2 + + # Move from LEFT to LEFT with blocking + assert ( + await redis_client.blmove( + key1, key2, ListDirection.LEFT, ListDirection.LEFT, 0.1 + ) + == "1" + ) + assert await redis_client.lrange(key1, 0, -1) == ["2"] + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4"] + + # Move from LEFT to RIGHT with blocking + assert ( + await redis_client.blmove( + key1, key2, ListDirection.LEFT, ListDirection.RIGHT, 0.1 + ) + == "2" + ) + assert await redis_client.lrange(key1, 0, -1) == [] + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4", "2"] + + # Move from RIGHT to LEFT non-existing destination with blocking + assert ( + await redis_client.blmove( + key2, key1, ListDirection.RIGHT, ListDirection.LEFT, 0.1 + ) + == "2" + ) + assert await redis_client.lrange(key2, 0, -1) == ["1", "3", "4"] + assert await redis_client.lrange(key1, 0, -1) == ["2"] + + # Move from RIGHT to RIGHT with blocking + assert ( + await redis_client.blmove( + key2, key1, ListDirection.RIGHT, ListDirection.RIGHT, 0.1 + ) + == "4" + ) + assert await redis_client.lrange(key2, 0, -1) == ["1", "3"] + assert await redis_client.lrange(key1, 0, -1) == ["2", "4"] + + # Non-existing source key with blocking + assert ( + await redis_client.blmove( + "{SameSlot}non_existing_key", + key1, + ListDirection.LEFT, + ListDirection.LEFT, + 0.1, + ) + is None + ) + + # Non-list source key with blocking + key3 = get_random_string(10) + assert await redis_client.set(key3, "value") == OK + with pytest.raises(RequestError): + await redis_client.blmove( + key3, key1, ListDirection.LEFT, ListDirection.LEFT, 0.1 + ) + + # Non-list destination key with blocking + with pytest.raises(RequestError): + await redis_client.blmove( + key1, key3, ListDirection.LEFT, ListDirection.LEFT, 0.1 + ) + + # BLMOVE is called against a non-existing key with no timeout, but we wrap the call in an asyncio timeout to + # avoid having the test block forever + async def endless_blmove_call(): + await redis_client.blmove( + "{SameSlot}non_existing_key", + key2, + ListDirection.LEFT, + ListDirection.RIGHT, + 0, + ) + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(endless_blmove_call(), timeout=3) + @pytest.mark.parametrize("cluster_mode", [True, False]) @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) async def test_sadd_srem_smembers_scard(self, redis_client: TRedisClient): @@ -3933,6 +4092,10 @@ async def test_multi_key_command_returns_cross_slot_error( redis_client.zunion(["def", "ghi"]), redis_client.zunion_withscores(["def", "ghi"]), redis_client.sort_store("abc", "zxy"), + redis_client.lmove("abc", "zxy", ListDirection.LEFT, ListDirection.LEFT), + redis_client.blmove( + "abc", "zxy", ListDirection.LEFT, ListDirection.LEFT, 1 + ), ] if not await check_if_server_version_lt(redis_client, "7.0.0"): diff --git a/python/python/tests/test_transaction.py b/python/python/tests/test_transaction.py index 3ce0a3acf5..413ff597ff 100644 --- a/python/python/tests/test_transaction.py +++ b/python/python/tests/test_transaction.py @@ -6,7 +6,7 @@ import pytest from glide import RequestError -from glide.async_commands.command_args import Limit, OrderBy +from glide.async_commands.command_args import Limit, ListDirection, OrderBy from glide.async_commands.core import ( GeospatialData, InsertPosition, @@ -182,6 +182,10 @@ async def transaction_test( args.append(OK) transaction.lrange(key5, 0, -1) args.append([value2, value]) + transaction.lmove(key5, key6, ListDirection.LEFT, ListDirection.LEFT) + args.append(value2) + transaction.blmove(key6, key5, ListDirection.LEFT, ListDirection.LEFT, 1) + args.append(value2) transaction.lpop_count(key5, 2) args.append([value2, value]) transaction.linsert(key5, InsertPosition.BEFORE, "non_existing_pivot", "element")