Skip to content

Commit fa76ac4

Browse files
authored
Add support for Redis 7 functions (#1998)
* add function support * linters * test fcall * decode reponses for unstable_r * linters * fix evalsho_ro test * fix eval_ro test * add response callbaks * linters
1 parent f2e3473 commit fa76ac4

File tree

7 files changed

+249
-16
lines changed

7 files changed

+249
-16
lines changed

redis/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,10 @@ class AbstractRedis:
733733
"CONFIG RESETSTAT": bool_ok,
734734
"CONFIG SET": bool_ok,
735735
"DEBUG OBJECT": parse_debug_object,
736+
"FUNCTION DELETE": bool_ok,
737+
"FUNCTION FLUSH": bool_ok,
738+
"FUNCTION LOAD": bool_ok,
739+
"FUNCTION RESTORE": bool_ok,
736740
"GEOHASH": lambda r: list(map(str_if_bytes, r)),
737741
"GEOPOS": lambda r: list(
738742
map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r)

redis/commands/core.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5429,6 +5429,131 @@ def readonly(self, **kwargs) -> ResponseT:
54295429
return self.execute_command("READONLY", **kwargs)
54305430

54315431

5432+
class FunctionCommands:
5433+
"""
5434+
Redis Function commands
5435+
"""
5436+
5437+
def function_load(
5438+
self,
5439+
engine: str,
5440+
library: str,
5441+
code: str,
5442+
replace: Optional[bool] = False,
5443+
description: Optional[str] = None,
5444+
) -> str:
5445+
"""
5446+
Load a library to Redis.
5447+
:param engine: the name of the execution engine for the library
5448+
:param library: the unique name of the library
5449+
:param code: the source code
5450+
:param replace: changes the behavior to replace the library if a library called
5451+
``library`` already exists
5452+
:param description: description to the library
5453+
5454+
For more information check https://redis.io/commands/function-load
5455+
"""
5456+
pieces = [engine, library]
5457+
if replace:
5458+
pieces.append("REPLACE")
5459+
if description is not None:
5460+
pieces.append(description)
5461+
pieces.append(code)
5462+
return self.execute_command("FUNCTION LOAD", *pieces)
5463+
5464+
def function_delete(self, library: str) -> str:
5465+
"""
5466+
Delete the library called ``library`` and all its functions.
5467+
5468+
For more information check https://redis.io/commands/function-delete
5469+
"""
5470+
return self.execute_command("FUNCTION DELETE", library)
5471+
5472+
def function_flush(self, mode: str = "SYNC") -> str:
5473+
"""
5474+
Deletes all the libraries.
5475+
5476+
For more information check https://redis.io/commands/function-flush
5477+
"""
5478+
return self.execute_command("FUNCTION FLUSH", mode)
5479+
5480+
def function_list(
5481+
self, library: Optional[str] = "*", withcode: Optional[bool] = False
5482+
) -> List:
5483+
"""
5484+
Return information about the functions and libraries.
5485+
:param library: pecify a pattern for matching library names
5486+
:param withcode: cause the server to include the libraries source
5487+
implementation in the reply
5488+
"""
5489+
args = ["LIBRARYNAME", library]
5490+
if withcode:
5491+
args.append("WITHCODE")
5492+
return self.execute_command("FUNCTION LIST", *args)
5493+
5494+
def _fcall(
5495+
self, command: str, function, numkeys: int, *keys_and_args: Optional[List]
5496+
) -> str:
5497+
return self.execute_command(command, function, numkeys, *keys_and_args)
5498+
5499+
def fcall(self, function, numkeys: int, *keys_and_args: Optional[List]) -> str:
5500+
"""
5501+
Invoke a function.
5502+
5503+
For more information check https://redis.io/commands/fcall
5504+
"""
5505+
return self._fcall("FCALL", function, numkeys, *keys_and_args)
5506+
5507+
def fcall_ro(self, function, numkeys: int, *keys_and_args: Optional[List]) -> str:
5508+
"""
5509+
This is a read-only variant of the FCALL command that cannot
5510+
execute commands that modify data.
5511+
5512+
For more information check https://redis.io/commands/fcal_ro
5513+
"""
5514+
return self._fcall("FCALL_RO", function, numkeys, *keys_and_args)
5515+
5516+
def function_dump(self) -> str:
5517+
"""
5518+
Return the serialized payload of loaded libraries.
5519+
5520+
For more information check https://redis.io/commands/function-dump
5521+
"""
5522+
from redis.client import NEVER_DECODE
5523+
5524+
options = {}
5525+
options[NEVER_DECODE] = []
5526+
5527+
return self.execute_command("FUNCTION DUMP", **options)
5528+
5529+
def function_restore(self, payload: str, policy: Optional[str] = "APPEND") -> str:
5530+
"""
5531+
Restore libraries from the serialized ``payload``.
5532+
You can use the optional policy argument to provide a policy
5533+
for handling existing libraries.
5534+
5535+
For more information check https://redis.io/commands/function-restore
5536+
"""
5537+
return self.execute_command("FUNCTION RESTORE", payload, policy)
5538+
5539+
def function_kill(self) -> str:
5540+
"""
5541+
Kill a function that is currently executing.
5542+
5543+
For more information check https://redis.io/commands/function-kill
5544+
"""
5545+
return self.execute_command("FUNCTION KILL")
5546+
5547+
def function_stats(self) -> list:
5548+
"""
5549+
Return information about the function that's currently running
5550+
and information about the available execution engines.
5551+
5552+
For more information check https://redis.io/commands/function-stats
5553+
"""
5554+
return self.execute_command("FUNCTION STATS")
5555+
5556+
54325557
AsyncClusterCommands = ClusterCommands
54335558

54345559

@@ -5474,6 +5599,7 @@ class CoreCommands(
54745599
ModuleCommands,
54755600
PubSubCommands,
54765601
ScriptCommands,
5602+
FunctionCommands,
54775603
):
54785604
"""
54795605
A class containing all of the implemented redis commands. This class is

redis/connection.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,10 +463,17 @@ def read_response(self, disable_decoding=False):
463463
self._next_response = False
464464
return response
465465

466-
response = self._reader.gets()
466+
if disable_decoding:
467+
response = self._reader.gets(False)
468+
else:
469+
response = self._reader.gets()
470+
467471
while response is False:
468472
self.read_from_socket()
469-
response = self._reader.gets()
473+
if disable_decoding:
474+
response = self._reader.gets(False)
475+
else:
476+
response = self._reader.gets()
470477
# if an older version of hiredis is installed, we need to attempt
471478
# to convert ResponseErrors to their appropriate types.
472479
if not HIREDIS_SUPPORTS_CALLABLE_ERRORS:

tests/conftest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,9 @@ def master_host(request):
434434
@pytest.fixture()
435435
def unstable_r(request):
436436
url = request.config.getoption("--redis-unstable-url")
437-
with _get_client(redis.Redis, request, from_url=url) as client:
437+
with _get_client(
438+
redis.Redis, request, from_url=url, decode_responses=True
439+
) as client:
438440
yield client
439441

440442

tests/test_commands.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ def test_client_unpause(self, r):
612612
@pytest.mark.onlynoncluster
613613
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
614614
def test_client_no_evict(self, unstable_r):
615-
assert unstable_r.client_no_evict("ON") == b"OK"
615+
assert unstable_r.client_no_evict("ON") == "OK"
616616
with pytest.raises(TypeError):
617617
unstable_r.client_no_evict()
618618

@@ -985,9 +985,9 @@ def test_unlink_with_multiple_keys(self, r):
985985
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
986986
def test_lcs(self, unstable_r):
987987
unstable_r.mset({"foo": "ohmytext", "bar": "mynewtext"})
988-
assert unstable_r.lcs("foo", "bar") == b"mytext"
988+
assert unstable_r.lcs("foo", "bar") == "mytext"
989989
assert unstable_r.lcs("foo", "bar", len=True) == 6
990-
result = [b"matches", [[[4, 7], [5, 8]]], b"len", 6]
990+
result = ["matches", [[[4, 7], [5, 8]]], "len", 6]
991991
assert unstable_r.lcs("foo", "bar", idx=True, minmatchlen=3) == result
992992
with pytest.raises(redis.ResponseError):
993993
assert unstable_r.lcs("foo", "bar", len=True, idx=True)
@@ -1522,24 +1522,24 @@ def test_brpoplpush_empty_string(self, r):
15221522
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
15231523
def test_blmpop(self, unstable_r):
15241524
unstable_r.rpush("a", "1", "2", "3", "4", "5")
1525-
res = [b"a", [b"1", b"2"]]
1525+
res = ["a", ["1", "2"]]
15261526
assert unstable_r.blmpop(1, "2", "b", "a", direction="LEFT", count=2) == res
15271527
with pytest.raises(TypeError):
15281528
unstable_r.blmpop(1, "2", "b", "a", count=2)
15291529
unstable_r.rpush("b", "6", "7", "8", "9")
1530-
assert unstable_r.blmpop(0, "2", "b", "a", direction="LEFT") == [b"b", [b"6"]]
1530+
assert unstable_r.blmpop(0, "2", "b", "a", direction="LEFT") == ["b", ["6"]]
15311531
assert unstable_r.blmpop(1, "2", "foo", "bar", direction="RIGHT") is None
15321532

15331533
@pytest.mark.onlynoncluster
15341534
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
15351535
def test_lmpop(self, unstable_r):
15361536
unstable_r.rpush("foo", "1", "2", "3", "4", "5")
1537-
result = [b"foo", [b"1", b"2"]]
1537+
result = ["foo", ["1", "2"]]
15381538
assert unstable_r.lmpop("2", "bar", "foo", direction="LEFT", count=2) == result
15391539
with pytest.raises(redis.ResponseError):
15401540
unstable_r.lmpop("2", "bar", "foo", direction="up", count=2)
15411541
unstable_r.rpush("bar", "a", "b", "c", "d")
1542-
assert unstable_r.lmpop("2", "bar", "foo", direction="LEFT") == [b"bar", [b"a"]]
1542+
assert unstable_r.lmpop("2", "bar", "foo", direction="LEFT") == ["bar", ["a"]]
15431543

15441544
def test_lindex(self, r):
15451545
r.rpush("a", "1", "2", "3")
@@ -2148,23 +2148,23 @@ def test_bzpopmin(self, r):
21482148
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
21492149
def test_zmpop(self, unstable_r):
21502150
unstable_r.zadd("a", {"a1": 1, "a2": 2, "a3": 3})
2151-
res = [b"a", [[b"a1", b"1"], [b"a2", b"2"]]]
2151+
res = ["a", [["a1", "1"], ["a2", "2"]]]
21522152
assert unstable_r.zmpop("2", ["b", "a"], min=True, count=2) == res
21532153
with pytest.raises(redis.DataError):
21542154
unstable_r.zmpop("2", ["b", "a"], count=2)
21552155
unstable_r.zadd("b", {"b1": 10, "ab": 9, "b3": 8})
2156-
assert unstable_r.zmpop("2", ["b", "a"], max=True) == [b"b", [[b"b1", b"10"]]]
2156+
assert unstable_r.zmpop("2", ["b", "a"], max=True) == ["b", [["b1", "10"]]]
21572157

21582158
@pytest.mark.onlynoncluster
21592159
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
21602160
def test_bzmpop(self, unstable_r):
21612161
unstable_r.zadd("a", {"a1": 1, "a2": 2, "a3": 3})
2162-
res = [b"a", [[b"a1", b"1"], [b"a2", b"2"]]]
2162+
res = ["a", [["a1", "1"], ["a2", "2"]]]
21632163
assert unstable_r.bzmpop(1, "2", ["b", "a"], min=True, count=2) == res
21642164
with pytest.raises(redis.DataError):
21652165
unstable_r.bzmpop(1, "2", ["b", "a"], count=2)
21662166
unstable_r.zadd("b", {"b1": 10, "ab": 9, "b3": 8})
2167-
res = [b"b", [[b"b1", b"10"]]]
2167+
res = ["b", [["b1", "10"]]]
21682168
assert unstable_r.bzmpop(0, "2", ["b", "a"], max=True) == res
21692169
assert unstable_r.bzmpop(1, "2", ["foo", "bar"], max=True) is None
21702170

tests/test_function.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import pytest
2+
3+
from redis.exceptions import ResponseError
4+
5+
function = "redis.register_function('myfunc', function(keys, args) return args[1] end)"
6+
function2 = "redis.register_function('hello', function() return 'Hello World' end)"
7+
set_function = "redis.register_function('set', function(keys, args) \
8+
return redis.call('SET', keys[1], args[1]) end)"
9+
get_function = "redis.register_function('get', function(keys, args) \
10+
return redis.call('GET', keys[1]) end)"
11+
12+
13+
@pytest.mark.onlynoncluster
14+
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
15+
class TestFunction:
16+
@pytest.fixture(autouse=True)
17+
def reset_functions(self, unstable_r):
18+
unstable_r.function_flush()
19+
20+
def test_function_load(self, unstable_r):
21+
assert unstable_r.function_load("Lua", "mylib", function)
22+
assert unstable_r.function_load("Lua", "mylib", function, replace=True)
23+
with pytest.raises(ResponseError):
24+
unstable_r.function_load("Lua", "mylib", function)
25+
with pytest.raises(ResponseError):
26+
unstable_r.function_load("Lua", "mylib2", function)
27+
28+
def test_function_delete(self, unstable_r):
29+
unstable_r.function_load("Lua", "mylib", set_function)
30+
with pytest.raises(ResponseError):
31+
unstable_r.function_load("Lua", "mylib", set_function)
32+
assert unstable_r.fcall("set", 1, "foo", "bar") == "OK"
33+
assert unstable_r.function_delete("mylib")
34+
with pytest.raises(ResponseError):
35+
unstable_r.fcall("set", 1, "foo", "bar")
36+
assert unstable_r.function_load("Lua", "mylib", set_function)
37+
38+
def test_function_flush(self, unstable_r):
39+
unstable_r.function_load("Lua", "mylib", function)
40+
assert unstable_r.fcall("myfunc", 0, "hello") == "hello"
41+
assert unstable_r.function_flush()
42+
with pytest.raises(ResponseError):
43+
unstable_r.fcall("myfunc", 0, "hello")
44+
with pytest.raises(ResponseError):
45+
unstable_r.function_flush("ABC")
46+
47+
def test_function_list(self, unstable_r):
48+
unstable_r.function_load("Lua", "mylib", function)
49+
res = [
50+
[
51+
"library_name",
52+
"mylib",
53+
"engine",
54+
"LUA",
55+
"description",
56+
None,
57+
"functions",
58+
[["name", "myfunc", "description", None]],
59+
],
60+
]
61+
assert unstable_r.function_list() == res
62+
assert unstable_r.function_list(library="*lib") == res
63+
assert unstable_r.function_list(withcode=True)[0][9] == function
64+
65+
def test_fcall(self, unstable_r):
66+
unstable_r.function_load("Lua", "mylib", set_function)
67+
unstable_r.function_load("Lua", "mylib2", get_function)
68+
assert unstable_r.fcall("set", 1, "foo", "bar") == "OK"
69+
assert unstable_r.fcall("get", 1, "foo") == "bar"
70+
with pytest.raises(ResponseError):
71+
unstable_r.fcall("myfunc", 0, "hello")
72+
73+
def test_fcall_ro(self, unstable_r):
74+
unstable_r.function_load("Lua", "mylib", function)
75+
assert unstable_r.fcall_ro("myfunc", 0, "hello") == "hello"
76+
unstable_r.function_load("Lua", "mylib2", set_function)
77+
with pytest.raises(ResponseError):
78+
unstable_r.fcall_ro("set", 1, "foo", "bar")
79+
80+
def test_function_dump_restore(self, unstable_r):
81+
unstable_r.function_load("Lua", "mylib", set_function)
82+
payload = unstable_r.function_dump()
83+
assert unstable_r.fcall("set", 1, "foo", "bar") == "OK"
84+
unstable_r.function_delete("mylib")
85+
with pytest.raises(ResponseError):
86+
unstable_r.fcall("set", 1, "foo", "bar")
87+
assert unstable_r.function_restore(payload)
88+
assert unstable_r.fcall("set", 1, "foo", "bar") == "OK"
89+
unstable_r.function_load("Lua", "mylib2", get_function)
90+
assert unstable_r.fcall("get", 1, "foo") == "bar"
91+
unstable_r.function_delete("mylib")
92+
assert unstable_r.function_restore(payload, "FLUSH")
93+
with pytest.raises(ResponseError):
94+
unstable_r.fcall("get", 1, "foo")

tests/test_scripting.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def test_eval(self, r):
3535
# @skip_if_server_version_lt("7.0.0") turn on after redis 7 release
3636
def test_eval_ro(self, unstable_r):
3737
unstable_r.set("a", "b")
38-
assert unstable_r.eval_ro("return redis.call('GET', KEYS[1])", 1, "a") == b"b"
38+
assert unstable_r.eval_ro("return redis.call('GET', KEYS[1])", 1, "a") == "b"
3939
with pytest.raises(redis.ResponseError):
4040
unstable_r.eval_ro("return redis.call('DEL', KEYS[1])", 1, "a")
4141

@@ -79,7 +79,7 @@ def test_evalsha_ro(self, unstable_r):
7979
unstable_r.set("a", "b")
8080
get_sha = unstable_r.script_load("return redis.call('GET', KEYS[1])")
8181
del_sha = unstable_r.script_load("return redis.call('DEL', KEYS[1])")
82-
assert unstable_r.evalsha_ro(get_sha, 1, "a") == b"b"
82+
assert unstable_r.evalsha_ro(get_sha, 1, "a") == "b"
8383
with pytest.raises(redis.ResponseError):
8484
unstable_r.evalsha_ro(del_sha, 1, "a")
8585

0 commit comments

Comments
 (0)