summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES13
-rw-r--r--README.md67
-rw-r--r--redis/client.py68
-rw-r--r--tests/pipeline.py43
-rw-r--r--tests/server_commands.py19
5 files changed, 181 insertions, 29 deletions
diff --git a/CHANGES b/CHANGES
index 0315540..a7d5f98 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,17 @@
-* 2.6.3 (in development)
+* 2.7.0 (in development)
* Added BITOP and BITCOUNT commands. Thanks Mark Tozzi.
* Added the TIME command. Thanks Jason Knight.
+ * Added support for LUA scripting. Thanks to Angus Peart, Drew Smathers,
+ Issac Kelly, Louis-Philippe Perron, Sean Bleier, Jeffrey Kaditz, and
+ Dvir Volk for various patches and contributions to this feature.
+ * Changed the default error handling in pipelines. By default, the first
+ error in a pipeline will now be raised. A new parameter to the
+ pipeline's execute, `raise_on_error`, can be set to False to keep the
+ old behavior of embeedding the exception instances in the result.
+ * Fixed a bug with pipelines where parse errors won't corrupt the
+ socket.
+ * Added the optional `number` argument to SRANDMEMBER for use with
+ Redis 2.6+ servers.
* 2.6.2
* `from_url` is now available as a classmethod on client classes. Thanks
Jon Parise for the patch.
diff --git a/README.md b/README.md
index 8e5f05e..e58eff0 100644
--- a/README.md
+++ b/README.md
@@ -269,10 +269,71 @@ which is much easier to read:
>>> r.transaction(client_side_incr, 'OUR-SEQUENCE-KEY')
[True]
-## Versioning scheme
+## LUA Scripting
+
+redis-py supports the EVAL, EVALSHA, and SCRIPT commands. However, there are
+a number of edge cases that make these commands tedious to use in real world
+scenarios. Therefore, redis-py exposes a Script object that makes scripting
+much easier to use.
+
+To create a Script instance, use the `register_script` function on a client
+instance passing the LUA code as the first argument. `register_script` returns
+a Script instance that you can use throughout your code.
+
+The following trivial LUA script accepts two parameters: the name of a key and a
+multiplier value. The script fetches the value stored in the key, multiplies
+it with the multiplier value and returns the result.
+
+ >>> r = redis.StrictRedis()
+ >>> lua = """
+ ... local value = redis.call('GET', KEYS[1])
+ ... value = tonumber(value)
+ ... return value * ARGV[1]"""
+ >>> multiply = r.register_script(lua)
+
+`multiply` is now a Script instance that is invoked by calling it like a
+function. Script instances accept the following optional arguments:
+
+* keys: A list of key names that the script will access. This becomes the
+ KEYS list in LUA.
+* args: A list of argument values. This becomes the ARGV list in LUA.
+* client: A redis-py Client or Pipeline instance that will invoke the
+ script. If client isn't specified, the client that intiially
+ created the Script instance (the one that `register_script` was
+ invoked from) will be used.
+
+Continuing the example from above:
+
+ >>> r.set('foo', 2)
+ >>> multiply(keys=['foo'], args=[5])
+ 10
+
+The value of key 'foo' is set to 2. When multiply is invoked, the 'foo' key is
+passed to the script along with the multiplier value of 5. LUA executes the
+script and returns the result, 10.
+
+Script instances can be executed using a different client instance, even one
+that points to a completely different Redis server.
+
+ >>> r2 = redis.StrictRedis('redis2.example.com')
+ >>> r2.set('foo', 3)
+ >>> multiply(keys=['foo'], args=[5], client=r2)
+ 15
+
+The Script object ensures that the LUA script is loaded into Redis's script
+cache. In the event of a NOSCRIPT error, it will load the script and retry
+executing it.
+
+Script objects can also be used in pipelines. The pipeline instance should be
+passed as the client argument when calling the script. Care is taken to ensure
+that the script is registered in Redis's script cache just prior to pipeline
+execution.
-redis-py is versioned after Redis. For example, redis-py 2.0.0 should
-support all the commands available in Redis 2.0.0.
+ >>> pipe = r.pipeline()
+ >>> pipe.set('foo', 5)
+ >>> multiply(keys=['foo'], args=[5], client=pipe)
+ >>> pipe.execute()
+ [True, 25]
Author
------
diff --git a/redis/client.py b/redis/client.py
index 96fe53b..42a01dd 100644
--- a/redis/client.py
+++ b/redis/client.py
@@ -1,6 +1,7 @@
from __future__ import with_statement
-from itertools import starmap
+from itertools import chain, starmap
import datetime
+import sys
import warnings
import time as mod_time
from redis._compat import (b, izip, imap, iteritems, dictkeys, dictvalues,
@@ -940,9 +941,16 @@ class StrictRedis(object):
"Remove and return a random member of set ``name``"
return self.execute_command('SPOP', name)
- def srandmember(self, name):
- "Return a random member of set ``name``"
- return self.execute_command('SRANDMEMBER', name)
+ def srandmember(self, name, number=None):
+ """
+ If ``number`` is None, returns a random member of set ``name``.
+
+ If ``number`` is supplied, returns a list of ``number`` random
+ memebers of set ``name``. Note this is only available when running
+ Redis 2.6+.
+ """
+ args = number and [number] or []
+ return self.execute_command('SRANDMEMBER', name, *args)
def srem(self, name, *values):
"Remove ``values`` from set ``name``"
@@ -1642,27 +1650,40 @@ class BasePipeline(object):
self.command_stack.append((args, options))
return self
- def _execute_transaction(self, connection, commands):
+ def _execute_transaction(self, connection, commands, raise_on_error):
+ cmds = chain([(('MULTI', ), {})], commands, [(('EXEC', ), {})])
all_cmds = SYM_EMPTY.join(
starmap(connection.pack_command,
- [args for args, options in commands]))
+ [args for args, options in cmds]))
connection.send_packed_command(all_cmds)
- # we don't care about the multi/exec any longer
- commands = commands[1:-1]
- # parse off the response for MULTI and all commands prior to EXEC.
- # the only data we care about is the response the EXEC
- # which is the last command
- for i in range(len(commands) + 1):
- self.parse_response(connection, '_')
+ # parse off the response for MULTI
+ self.parse_response(connection, '_')
+ # and all the other commands
+ errors = []
+ for i, _ in enumerate(commands):
+ try:
+ self.parse_response(connection, '_')
+ except ResponseError:
+ errors.append((i, sys.exc_info()[1]))
+
# parse the EXEC.
response = self.parse_response(connection, '_')
if response is None:
raise WatchError("Watched variable changed.")
+ # put any parse errors into the response
+ for i, e in errors:
+ response.insert(i, e)
+
if len(response) != len(commands):
raise ResponseError("Wrong number of response items from "
"pipeline execution")
+
+ # find any errors in the response and raise if necessary
+ if raise_on_error:
+ self.raise_first_error(response)
+
# We have to run response callbacks manually
data = []
for r, cmd in izip(response, commands):
@@ -1674,14 +1695,22 @@ class BasePipeline(object):
data.append(r)
return data
- def _execute_pipeline(self, connection, commands):
+ def _execute_pipeline(self, connection, commands, raise_on_error):
# build up all commands into a single request to increase network perf
all_cmds = SYM_EMPTY.join(
starmap(connection.pack_command,
[args for args, options in commands]))
connection.send_packed_command(all_cmds)
- return [self.parse_response(connection, args[0], **options)
- for args, options in commands]
+ response = [self.parse_response(connection, args[0], **options)
+ for args, options in commands]
+ if raise_on_error:
+ self.raise_first_error(response)
+ return response
+
+ def raise_first_error(self, response):
+ for r in response:
+ if isinstance(r, ResponseError):
+ raise r
def parse_response(self, connection, command_name, **options):
result = StrictRedis.parse_response(
@@ -1703,13 +1732,12 @@ class BasePipeline(object):
if not exist:
immediate('SCRIPT', 'LOAD', s.script, **{'parse': 'LOAD'})
- def execute(self):
+ def execute(self, raise_on_error=True):
"Execute all the commands in the current pipeline"
if self.scripts:
self.load_scripts()
stack = self.command_stack
if self.transaction or self.explicit_transaction:
- stack = [(('MULTI', ), {})] + stack + [(('EXEC', ), {})]
execute = self._execute_transaction
else:
execute = self._execute_pipeline
@@ -1723,7 +1751,7 @@ class BasePipeline(object):
self.connection = conn
try:
- return execute(conn, stack)
+ return execute(conn, stack, raise_on_error)
except ConnectionError:
conn.disconnect()
# if we were watching a variable, the watch is no longer valid
@@ -1736,7 +1764,7 @@ class BasePipeline(object):
"one or more keys")
# otherwise, it's safe to retry since the transaction isn't
# predicated on any state
- return execute(conn, stack)
+ return execute(conn, stack, raise_on_error)
finally:
self.reset()
diff --git a/tests/pipeline.py b/tests/pipeline.py
index 89c5742..74d7f7b 100644
--- a/tests/pipeline.py
+++ b/tests/pipeline.py
@@ -61,12 +61,13 @@ class PipelineTestCase(unittest.TestCase):
pipe.set('a', int(a) + 1)
self.assertRaises(redis.WatchError, pipe.execute)
- def test_invalid_command_in_pipeline(self):
- # all commands but the invalid one should be excuted correctly
+ def test_exec_error_in_response(self):
+ # an invalid pipeline command at exec time adds the exception instance
+ # to the list of returned values
self.client['c'] = 'a'
with self.client.pipeline() as pipe:
pipe.set('a', 1).set('b', 2).lpush('c', 3).set('d', 4)
- result = pipe.execute()
+ result = pipe.execute(raise_on_error=False)
self.assertEquals(result[0], True)
self.assertEquals(self.client['a'], b('1'))
@@ -83,6 +84,42 @@ class PipelineTestCase(unittest.TestCase):
self.assertEquals(pipe.set('z', 'zzz').execute(), [True])
self.assertEquals(self.client['z'], b('zzz'))
+ def test_exec_error_raised(self):
+ self.client['c'] = 'a'
+ with self.client.pipeline() as pipe:
+ pipe.set('a', 1).set('b', 2).lpush('c', 3).set('d', 4)
+ self.assertRaises(redis.ResponseError, pipe.execute)
+
+ # make sure the pipe was restored to a working state
+ self.assertEquals(pipe.set('z', 'zzz').execute(), [True])
+ self.assertEquals(self.client['z'], b('zzz'))
+
+ def test_parse_error_in_response(self):
+ with self.client.pipeline() as pipe:
+ # the zrem is invalid because we don't pass any keys to it
+ pipe.set('a', 1).zrem('b').set('b', 2)
+ result = pipe.execute(raise_on_error=False)
+
+ self.assertEquals(result[0], True)
+ self.assertEquals(self.client['a'], b('1'))
+ self.assert_(isinstance(result[1], redis.ResponseError))
+ self.assertEquals(result[2], True)
+ self.assertEquals(self.client['b'], b('2'))
+
+ # make sure the pipe was restored to a working state
+ self.assertEquals(pipe.set('z', 'zzz').execute(), [True])
+ self.assertEquals(self.client['z'], b('zzz'))
+
+ def test_parse_error_raised(self):
+ with self.client.pipeline() as pipe:
+ # the zrem is invalid because we don't pass any keys to it
+ pipe.set('a', 1).zrem('b').set('b', 2)
+ self.assertRaises(redis.ResponseError, pipe.execute)
+
+ # make sure the pipe was restored to a working state
+ self.assertEquals(pipe.set('z', 'zzz').execute(), [True])
+ self.assertEquals(self.client['z'], b('zzz'))
+
def test_watch_succeed(self):
self.client.set('a', 1)
self.client.set('b', 2)
diff --git a/tests/server_commands.py b/tests/server_commands.py
index b7dcf53..e8f6c7a 100644
--- a/tests/server_commands.py
+++ b/tests/server_commands.py
@@ -550,7 +550,11 @@ class ServerCommandsTestCase(unittest.TestCase):
del self.client['a']
# real logic
version = self.client.info()['redis_version']
- if StrictVersion(version) >= StrictVersion('1.3.4'):
+ if StrictVersion(version) >= StrictVersion('2.4.0'):
+ self.assertEqual(1, self.client.lpush('a', 'b'))
+ self.assertEqual(2, self.client.lpush('a', 'a'))
+ self.assertEqual(4, self.client.lpush('a', 'b', 'a'))
+ elif StrictVersion(version) >= StrictVersion('1.3.4'):
self.assertEqual(1, self.client.lpush('a', 'b'))
self.assertEqual(2, self.client.lpush('a', 'a'))
else:
@@ -684,7 +688,11 @@ class ServerCommandsTestCase(unittest.TestCase):
del self.client['a']
# real logic
version = self.client.info()['redis_version']
- if StrictVersion(version) >= StrictVersion('1.3.4'):
+ if StrictVersion(version) >= StrictVersion('2.4.0'):
+ self.assertEqual(1, self.client.rpush('a', 'a'))
+ self.assertEqual(2, self.client.rpush('a', 'b'))
+ self.assertEqual(4, self.client.rpush('a', 'a', 'b'))
+ elif StrictVersion(version) >= StrictVersion('1.3.4'):
self.assertEqual(1, self.client.rpush('a', 'a'))
self.assertEqual(2, self.client.rpush('a', 'b'))
else:
@@ -861,6 +869,13 @@ class ServerCommandsTestCase(unittest.TestCase):
self.make_set('a', 'abc')
self.assert_(self.client.srandmember('a') in b('abc'))
+ version = self.client.info()['redis_version']
+ if StrictVersion(version) >= StrictVersion('2.6.0'):
+ randoms = self.client.srandmember('a', number=2)
+ self.assertEquals(len(randoms), 2)
+ for r in randoms:
+ self.assert_(r in b('abc'))
+
def test_srem(self):
# key is not set
self.assertEquals(self.client.srem('a', 'a'), False)