diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d16a5895..038ae34bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ * Python: Added FUNCTION FLUSH command ([#1700](https://github.com/aws/glide-for-redis/pull/1700)) * Python: Added FUNCTION DELETE command ([#1714](https://github.com/aws/glide-for-redis/pull/1714)) * Python: Added SSCAN command ([#1709](https://github.com/aws/glide-for-redis/pull/1709)) +* Python: Added LCS command ([#1716](https://github.com/aws/glide-for-redis/pull/1716)) ### 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/async_commands/core.py b/python/python/glide/async_commands/core.py index 9cf027a43f..168c790117 100644 --- a/python/python/glide/async_commands/core.py +++ b/python/python/glide/async_commands/core.py @@ -5488,3 +5488,173 @@ def try_get_pubsub_message(self) -> Optional[PubSubMsg]: >>> pubsub_msg = listening_client.try_get_pubsub_message() """ ... + + async def lcs( + self, + key1: str, + key2: str, + ) -> str: + """ + Returns the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + + Returns: + A String containing the longest common subsequence between the 2 strings. + An empty String is returned if the keys do not exist or have no common subsequences. + + Examples: + >>> await client.mset({"testKey1" : "abcd", "testKey2": "axcd"}) + 'OK' + >>> await client.lcs("testKey1", "testKey2") + 'acd' + + Since: Redis version 7.0.0. + """ + args = [key1, key2] + + return cast( + str, + await self._execute_command(RequestType.LCS, args), + ) + + async def lcs_len( + self, + key1: str, + key2: str, + ) -> int: + """ + Returns the length of the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + + Returns: + The length of the longest common subsequence between the 2 strings. + + Examples: + >>> await client.mset({"testKey1" : "abcd", "testKey2": "axcd"}) + 'OK' + >>> await client.lcs_len("testKey1", "testKey2") + 3 # the length of the longest common subsequence between these 2 strings ("acd") is 3. + + Since: Redis version 7.0.0. + """ + args = [key1, key2, "LEN"] + + return cast( + int, + await self._execute_command(RequestType.LCS, args), + ) + + async def lcs_idx( + self, + key1: str, + key2: str, + min_match_len: Optional[int] = None, + with_match_len: Optional[bool] = False, + ) -> Mapping[str, Union[list[list[Union[list[int], int]]], int]]: + """ + Returns the indices and length of the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + min_match_len (Optional[int]): The minimum length of matches to include in the result. + with_match_len (Optional[bool]): If True, include the length of the substring matched for each substring. + + Returns: + A Mapping containing the indices of the longest common subsequence between the + 2 strings and the length of the longest common subsequence. The resulting map contains two + keys, "matches" and "len": + - "len" is mapped to the length of the longest common subsequence between the 2 strings. + - "matches" is mapped to a three dimensional int array that stores pairs of indices that + represent the location of the common subsequences in the strings held by key1 and key2, + with the length of the match after each matches, if with_match_len is enabled. + + Examples: + >>> await client.mset({"testKey1" : "abcd1234", "testKey2": "bcdef1234"}) + 'OK' + >>> await client.lcs_idx("testKey1", "testKey2") + { + 'matches': [ + [ + [4, 7], # starting and ending indices of the subsequence "1234" in "abcd1234" (testKey1) + [5, 8], # starting and ending indices of the subsequence "1234" in "bcdef1234" (testKey2) + ], + [ + [1, 3], # starting and ending indices of the subsequence "bcd" in "abcd1234" (testKey1) + [0, 2], # starting and ending indices of the subsequence "bcd" in "bcdef1234" (testKey2) + ], + ], + 'len': 7 # length of the entire longest common subsequence + } + >>> await client.lcs_idx("testKey1", "testKey2", min_match_len=4) + { + 'matches': [ + [ + [4, 7], + [5, 8], + ], + # the other match with a length of 3 is excluded + ], + 'len': 7 + } + >>> await client.lcs_idx("testKey1", "testKey2", with_match_len=True) + { + 'matches': [ + [ + [4, 7], + [5, 8], + 4, # length of this match ("1234") + ], + [ + [1, 3], + [0, 2], + 3, # length of this match ("bcd") + ], + ], + 'len': 7 + } + + Since: Redis version 7.0.0. + """ + args = [key1, key2, "IDX"] + + if min_match_len is not None: + args.extend(["MINMATCHLEN", str(min_match_len)]) + + if with_match_len: + args.append("WITHMATCHLEN") + + return cast( + Mapping[str, Union[list[list[Union[list[int], int]]], int]], + await self._execute_command(RequestType.LCS, args), + ) diff --git a/python/python/glide/async_commands/transaction.py b/python/python/glide/async_commands/transaction.py index ced995e9e2..fe9bd72178 100644 --- a/python/python/glide/async_commands/transaction.py +++ b/python/python/glide/async_commands/transaction.py @@ -3949,6 +3949,110 @@ def sscan( return self.append_command(RequestType.SScan, args) + def lcs( + self: TTransaction, + key1: str, + key2: str, + ) -> TTransaction: + """ + Returns the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + + Command Response: + A String containing the longest common subsequence between the 2 strings. + An empty String is returned if the keys do not exist or have no common subsequences. + + Since: Redis version 7.0.0. + """ + args = [key1, key2] + + return self.append_command(RequestType.LCS, args) + + def lcs_len( + self: TTransaction, + key1: str, + key2: str, + ) -> TTransaction: + """ + Returns the length of the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + + Command Response: + The length of the longest common subsequence between the 2 strings. + + Since: Redis version 7.0.0. + """ + args = [key1, key2, "LEN"] + + return self.append_command(RequestType.LCS, args) + + def lcs_idx( + self: TTransaction, + key1: str, + key2: str, + min_match_len: Optional[int] = None, + with_match_len: Optional[bool] = False, + ) -> TTransaction: + """ + Returns the indices and length of the longest common subsequence between strings stored at key1 and key2. + + Note that this is different than the longest common string algorithm, since + matching characters in the two strings do not need to be contiguous. + + For instance the LCS between "foo" and "fao" is "fo", since scanning the two strings + from left to right, the longest common set of characters is composed of the first "f" and then the "o". + + See https://valkey.io/commands/lcs for more details. + + Args: + key1 (str): The key that stores the first string. + key2 (str): The key that stores the second string. + min_match_len (Optional[int]): The minimum length of matches to include in the result. + with_match_len (Optional[bool]): If True, include the length of the substring matched for each substring. + + Command Response: + A Map containing the indices of the longest common subsequence between the + 2 strings and the length of the longest common subsequence. The resulting map contains two + keys, "matches" and "len": + - "len" is mapped to the length of the longest common subsequence between the 2 strings. + - "matches" is mapped to a three dimensional int array that stores pairs of indices that + represent the location of the common subsequences in the strings held by key1 and key2, + with the length of the match after each matches, if with_match_len is enabled. + + Since: Redis version 7.0.0. + """ + args = [key1, key2, "IDX"] + + if min_match_len is not None: + args.extend(["MINMATCHLEN", str(min_match_len)]) + + if with_match_len: + args.append("WITHMATCHLEN") + + return self.append_command(RequestType.LCS, args) + class Transaction(BaseTransaction): """ diff --git a/python/python/tests/test_async_client.py b/python/python/tests/test_async_client.py index c1a2e2a6fb..46b7b55428 100644 --- a/python/python/tests/test_async_client.py +++ b/python/python/tests/test_async_client.py @@ -7160,6 +7160,147 @@ async def test_standalone_client_random_key(self, redis_client: GlideClient): # DB 0 should still have no keys, so random_key should still return None assert await redis_client.random_key() is None + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_lcs(self, redis_client: GlideClient): + min_version = "7.0.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + key1 = "testKey1" + value1 = "abcd" + key2 = "testKey2" + value2 = "axcd" + nonexistent_key = "nonexistent_key" + expected_subsequence = "acd" + expected_subsequence_with_nonexistent_key = "" + assert await redis_client.mset({key1: value1, key2: value2}) == OK + assert await redis_client.lcs(key1, key2) == expected_subsequence + assert ( + await redis_client.lcs(key1, nonexistent_key) + == expected_subsequence_with_nonexistent_key + ) + lcs_non_string_key = "lcs_non_string_key" + assert await redis_client.sadd(lcs_non_string_key, ["Hello", "world"]) == 2 + with pytest.raises(RequestError): + await redis_client.lcs(key1, lcs_non_string_key) + + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_lcs_len(self, redis_client: GlideClient): + min_version = "7.0.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + key1 = "testKey1" + value1 = "abcd" + key2 = "testKey2" + value2 = "axcd" + nonexistent_key = "nonexistent_key" + expected_subsequence_length = 3 + expected_subsequence_length_with_nonexistent_key = 0 + assert await redis_client.mset({key1: value1, key2: value2}) == OK + assert await redis_client.lcs_len(key1, key2) == expected_subsequence_length + assert ( + await redis_client.lcs_len(key1, nonexistent_key) + == expected_subsequence_length_with_nonexistent_key + ) + lcs_non_string_key = "lcs_non_string_key" + assert await redis_client.sadd(lcs_non_string_key, ["Hello", "world"]) == 2 + with pytest.raises(RequestError): + await redis_client.lcs_len(key1, lcs_non_string_key) + + @pytest.mark.parametrize("cluster_mode", [False]) + @pytest.mark.parametrize("protocol", [ProtocolVersion.RESP2, ProtocolVersion.RESP3]) + async def test_lcs_idx(self, redis_client: GlideClient): + min_version = "7.0.0" + if await check_if_server_version_lt(redis_client, min_version): + return pytest.mark.skip(reason=f"Redis version required >= {min_version}") + key1 = "testKey1" + value1 = "abcd1234" + key2 = "testKey2" + value2 = "bcdef1234" + nonexistent_key = "nonexistent_key" + expected_response_no_min_match_len_no_with_match_len = { + "matches": [ + [ + [4, 7], + [5, 8], + ], + [ + [1, 3], + [0, 2], + ], + ], + "len": 7, + } + expected_response_with_min_match_len_equals_four_no_with_match_len = { + "matches": [ + [ + [4, 7], + [5, 8], + ], + ], + "len": 7, + } + expected_response_no_min_match_len_with_match_len = { + "matches": [ + [ + [4, 7], + [5, 8], + 4, + ], + [ + [1, 3], + [0, 2], + 3, + ], + ], + "len": 7, + } + expected_response_with_min_match_len_equals_four_and_with_match_len = { + "matches": [ + [ + [4, 7], + [5, 8], + 4, + ], + ], + "len": 7, + } + expected_response_with_nonexistent_key = { + "matches": [], + "len": 0, + } + assert await redis_client.mset({key1: value1, key2: value2}) == OK + assert ( + await redis_client.lcs_idx(key1, key2) + == expected_response_no_min_match_len_no_with_match_len + ) + assert ( + await redis_client.lcs_idx(key1, key2, min_match_len=4) + == expected_response_with_min_match_len_equals_four_no_with_match_len + ) + assert ( + # negative min_match_len should have no affect on the output + await redis_client.lcs_idx(key1, key2, min_match_len=-3) + == expected_response_no_min_match_len_no_with_match_len + ) + assert ( + await redis_client.lcs_idx(key1, key2, with_match_len=True) + == expected_response_no_min_match_len_with_match_len + ) + assert ( + await redis_client.lcs_idx(key1, key2, min_match_len=4, with_match_len=True) + == expected_response_with_min_match_len_equals_four_and_with_match_len + ) + assert ( + await redis_client.lcs_idx(key1, nonexistent_key) + == expected_response_with_nonexistent_key + ) + lcs_non_string_key = "lcs_non_string_key" + assert await redis_client.sadd(lcs_non_string_key, ["Hello", "world"]) == 2 + with pytest.raises(RequestError): + await redis_client.lcs_idx(key1, lcs_non_string_key) + class TestMultiKeyCommandCrossSlot: @pytest.mark.parametrize("cluster_mode", [True]) @@ -7225,6 +7366,9 @@ async def test_multi_key_command_returns_cross_slot_error( redis_client.sintercard(["def", "ghi"]), redis_client.lmpop(["def", "ghi"], ListDirection.LEFT), redis_client.blmpop(["def", "ghi"], ListDirection.LEFT, 1), + redis_client.lcs("abc", "def"), + redis_client.lcs_len("abc", "def"), + redis_client.lcs_idx("abc", "def"), ] ) diff --git a/python/python/tests/test_transaction.py b/python/python/tests/test_transaction.py index 9cda1f382a..32e6e5c3b7 100644 --- a/python/python/tests/test_transaction.py +++ b/python/python/tests/test_transaction.py @@ -88,6 +88,8 @@ async def transaction_test( key19 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # bitmap key20 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # bitmap key22 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # getex + key23 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # string + key24 = "{{{}}}:{}".format(keyslot, get_random_string(3)) # string value = datetime.now(timezone.utc).strftime("%m/%d/%Y, %H:%M:%S") value2 = get_random_string(5) @@ -610,6 +612,19 @@ async def transaction_test( transaction.bzmpop([key16], ScoreFilter.MIN, 0.1, 2) args.append([key16, {"a": 1.0, "b": 2.0}]) + transaction.mset({key23: "abcd1234", key24: "bcdef1234"}) + args.append(OK) + transaction.lcs(key23, key24) + args.append("bcd1234") + transaction.lcs_len(key23, key24) + args.append(7) + transaction.lcs_idx(key23, key24) + args.append({"matches": [[[4, 7], [5, 8]], [[1, 3], [0, 2]]], "len": 7}) + transaction.lcs_idx(key23, key24, min_match_len=4) + args.append({"matches": [[[4, 7], [5, 8]]], "len": 7}) + transaction.lcs_idx(key23, key24, with_match_len=True) + args.append({"matches": [[[4, 7], [5, 8], 4], [[1, 3], [0, 2], 3]], "len": 7}) + return args