Last active
August 29, 2015 14:00
-
-
Save dpopowich/11374809 to your computer and use it in GitHub Desktop.
A python module providing a pythonic interface to redis-py's StrictRedis class.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright (c) 2014 Daniel Popowich | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in | |
# all copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | |
# THE SOFTWARE. | |
''' | |
This gist accompanies a blog post at: | |
http://www.artandlogic.com/blog/2014/05/making-an-inherited-api-pythonic/ | |
This module provides classes which offer pythonic interfaces to | |
redis-py, the defacto standard python package for redis. | |
An abstract base class, RedisBase, provides base implementation for | |
five classes that map to the five data structures in redis: | |
RedisString | |
RedisSet | |
RedisSortedSet | |
RedisList | |
RedisHash | |
Each of the subclasses delegates redis commands to the underlying | |
redis-py method, giving a more pythonic interface to the redis | |
types. Typical usage might be:: | |
class UserSession(RedisString): | |
pass | |
session = UserSession('phred') | |
session.set(someValue) | |
The above will create a key in redis, "usersession:phred", with the | |
value `str(someValue)`. | |
The prefix can be specified in one of three ways: | |
1) Setting a `prefix` attribute on the class. | |
2) passing it to the constructor, e.g.: | |
RedisString("mykey", prefix="myprefix") | |
3) the default is the class name, lower-cased. A colon will be | |
used to separate the prefix from the key. | |
See the redis documentation at http://redis.io/commands for details | |
about each data structure's methods. | |
NOTE: some of the classes use python's data model to offer python | |
semantics for certain actions. For example if `rset` is an | |
instance of RedisSet, then both of these are equivalent: | |
if rset.sismember(item): | |
... | |
if item in rset: | |
... | |
See the doc strings for each class for what is available. (Way | |
more is possible than is implemented in this module. Fork and | |
extend!) | |
========== | |
INSTALLING | |
========== | |
The only dependency of this module is redis-py: | |
pip install redis | |
======= | |
TESTING | |
======= | |
If pytest is installed, you can run the test suite: | |
py.test redis-pythonic.py | |
BUT CAUTION: the test-suite flushes the redis database! Use a test | |
database!!! | |
''' | |
import redis | |
def get_redis_connection(*args, **kwargs): | |
'''For purposes of this gist, we need a simple mechanism to manage a | |
global redis connection. Production code will want to replace this | |
or implement a more robust version. | |
With args or kwargs, initializes (or resets) a global connection | |
object. Without args or kwargs, returns the previously created | |
redis connection, or creates a default connection if not previously | |
called with args or kwargs. | |
Args and kwargs are passed directly to the redis.StrictRedis | |
constructor: | |
_the_connection = redis.StrictRedis(*args, **kwargs) | |
''' | |
global _the_connection | |
if args or kwargs: | |
_the_connection = redis.StrictRedis(*args, **kwargs) | |
else: | |
try: | |
_the_connection | |
except NameError: | |
_the_connection = redis.StrictRedis() | |
return _the_connection | |
class RedisBase(object): | |
'''See module doc string. | |
Essentially, any redis command that accepts a "key" as its first | |
argument is available as a method on a subclass and that method | |
delegates the call to the underlying StrictRedis connection | |
instance. | |
''' | |
# http://redis.io/commands#generic | |
_redis_methods = ['delete', 'exists', 'expire', | |
'expireat', 'persist', 'pexpire', | |
'pexpireat', 'pttl', 'ttl',] | |
def __init__(self, key, prefix=None, sep=':'): | |
'''Create a redis helper object. | |
Args: | |
key: the key name | |
prefix: (optional) the key's prefix. Uses class `prefix` | |
attribute or class name (lower-cased) as prefix if not | |
specified here. The final key will be `prefix` + `sep` + | |
`key`, unless prefix is an empty string in which case | |
`key` will be used with no prefix. | |
sep: The string to separate `prefix` and `key` in generating | |
the final key. | |
''' | |
if self.__class__ == RedisBase: | |
raise RuntimeError, 'RedisBase is an abstract base class' | |
# assert some order over the key space | |
assert len(key) | |
if prefix != '': | |
try: | |
if prefix is None: | |
# see if prefix is set on the class | |
prefix = self.prefix | |
except AttributeError: | |
prefix = self.__class__.__name__.lower() | |
key = prefix + sep + str(key) if prefix else str(key) | |
self.key = key | |
def __getattribute__(self, name): | |
'The magic that delegates redis commands to the underlying redis-py methods' | |
def helper(*args, **kwargs): | |
method = getattr(get_redis_connection(), name) | |
return method(self.key, *args, **kwargs) | |
methods = object.__getattribute__(self, '_redis_methods') | |
if name in methods: | |
return helper | |
return object.__getattribute__(self, name) | |
class RedisString(RedisBase): | |
'''Model a redis String. | |
Also supports len(). | |
''' | |
# http://redis.io/commands#string | |
_redis_methods = RedisBase._redis_methods + \ | |
['append', 'bitcount', 'decr', 'decrby', 'get', | |
'getbit', 'getrange', 'getset', 'incr', 'incrby', 'incrbyfloat', | |
'psetex', 'set', 'setbit', 'setex', 'setnx', 'setrange', | |
'strlen'] | |
def __len__(self): | |
return self.strlen() | |
class RedisSet(RedisBase): | |
'''Model a redis Set. | |
Also supports `in`, len(), and iteration. | |
''' | |
# http://redis.io/commands#set | |
_redis_methods = RedisBase._redis_methods + \ | |
['sadd', 'scard', 'sdiff', 'sinter', | |
'sismember', 'smembers', 'spop', | |
'srandmember', 'srem', 'sunion', ] | |
def __contains__(self, item): | |
return self.sismember(item) | |
def __len__(self): | |
return self.scard() | |
def __iter__(self): | |
return iter(self.smembers()) | |
class RedisSortedSet(RedisBase): | |
'''Model a redis Sorted Set | |
Also supports `in`, len(), and iteration. | |
''' | |
# http://redis.io/commands#sorted_set | |
_redis_methods = RedisBase._redis_methods + \ | |
['zadd', 'zcard', 'zcount', 'zincrby', | |
'zrange', 'zrangebyscore', | |
'zrank', 'zrem', 'zremrangebyrank', | |
'zremrangebyscore', 'zrevrange', 'zrevrangebyscore', | |
'zrevrank', 'zscore', ] | |
def __contains__(self, item): | |
return self.zscore(item) | |
def __len__(self): | |
return self.zcard() | |
def __iter__(self): | |
return iter(self.zrange(0, -1)) | |
class RedisList(RedisBase): | |
'''Model a redis List | |
Also supports len(), and iteration. | |
''' | |
# http://redis.io/commands#list | |
_redis_methods = RedisBase._redis_methods + \ | |
['blpop', 'brpop', 'lindex', 'linsert', 'llen', | |
'lpop', 'lpush', 'lpushx', 'lrange', 'lrem', | |
'lset', 'ltrim', 'rpop', 'rpush', 'rpushx',] | |
def __len__(self): | |
return self.llen() | |
def __iter__(self): | |
return iter(self.lrange(0, -1)) | |
class RedisHash(RedisBase): | |
'''Model a redis Hash. | |
Also supports len() and iteration over keys. | |
''' | |
# http://redis.io/commands#hash | |
_redis_methods = RedisBase._redis_methods + \ | |
['hdel', 'hexists', 'hget', 'hgetall', | |
'hincrby', 'hincrbyfloat', 'hkeys', 'hlen', | |
'hmget', 'hmset', 'hset', 'hsetnx', 'hvals',] | |
def __len__(self): | |
return self.hlen() | |
def __iter__(self): | |
return iter(self.hkeys()) | |
# if pytest is available... | |
try: | |
import pytest | |
import random | |
import time | |
@pytest.fixture() | |
def flushdb(): | |
'''Flush the database. | |
''' | |
return get_redis_connection().flushdb() | |
@pytest.fixture(scope='session') | |
def proper_names(): | |
'return list of names' | |
# 100 names | |
names = '''QWFyb24KQWRhCkFkYW0KQWRsYWkKQWRyaWFuCkFkcmllbm5lCkFnYXRoYQpBZ25ldGhhCkFo | |
bWVkCkFobWV0CkFpbWVlCkFsCkFsYWluCkFsYW4KQWxhc2RhaXIKQWxhc3RhaXIKQWxiZXJ0 | |
CkFsYmVydG8KQWxlamFuZHJvCkFsZXgKQWxleGEKQWxleGFuZGVyCkFsZXhpYQpBbGV4aXMK | |
QWxmCkFsZnJlZApBbGljZQpBbGlzb24KQWxsYW4KQWxsZW4KQWx2aW4KQW1hbmRhCkFtYXJ0 | |
aApBbWVkZW8KQW1pCkFtaWdvCkFtaXIKQW1vcwpBbXkKQW5haXMKQW5hc3Rhc2lhCkFuYXRv | |
bGUKQW5hdG9seQpBbmRlcnNvbgpBbmRyZQpBbmRyZWEKQW5kcmVhcwpBbmRyZXcKQW5kcmll | |
cwpBbmR5CkFuZ2VsYQpBbmdlbGljYQpBbmd1cwpBbml0YQpBbm4KQW5uYQpBbm5hcmQKQW5u | |
ZQpBbm5pZQpBbnRoZWEKQW50aG9ueQpBbnRvbgpBbnRvbmVsbGEKQW50b25pbwpBbnRvbnkK | |
QW55YQpBcHJpbApBcmNoaWJhbGQKQXJjaGllCkFyaWVsCkFybGVuZQpBcm5lCkFybm9sZApB | |
cnQKQXJ0aHVyCkF0aGVsCkF1YmVyb24KQXVicmV5CkF1ZHJleQpBdWd1c3R1cwpBdmVyeQpB | |
eGVsCkJhcmJhcmEKQmFyYnJhCkJhcm5leQpCYXJyZXR0CkJhcnJpbwpCYXJyeQpCYXJ0CkJh | |
cnRvbgpCZWEKQmVja2llCkJlY2t5CkJlaGRhZApCZWxpbmRhCkJlbgpCZW5qYW1pbgpCZW5u | |
eQpCZW5zb24KQmVybmFyZAo='''.decode('base64').split() | |
return names | |
def test_helper(): | |
'test that prefix expectations are met' | |
class PrefixTest(RedisString): | |
pass | |
with pytest.raises(TypeError): | |
# need a key | |
PrefixTest() | |
# assert various ways a prefix can be set | |
# class name | |
assert PrefixTest('mykey').key == 'prefixtest:mykey' | |
# explicit | |
assert PrefixTest('mykey', prefix='foo').key == 'foo:mykey' | |
# class attribute | |
PrefixTest.prefix = 'bar' | |
assert PrefixTest('mykey').key == 'bar:mykey' | |
# different separator | |
assert PrefixTest('mykey', sep='^').key == 'bar^mykey' | |
# empty prefix | |
assert PrefixTest('keyonly', prefix='').key == 'keyonly' | |
# key needs to be non-empty | |
with pytest.raises(AssertionError): | |
PrefixTest('', prefix='') | |
assert PrefixTest('xx', prefix='').key == 'xx' | |
def test_no_save(flushdb): | |
'creating keys in python should not create keys in redis' | |
assert flushdb | |
for x in range(100): | |
key = 's%d' % x | |
RedisString('str' + key) | |
RedisSet('set' + key) | |
RedisSortedSet('zset' + key) | |
RedisHash('hash' + key) | |
assert not get_redis_connection().keys() | |
# class used in next two tests | |
class StringTest(RedisString): | |
pass | |
def test_string(flushdb, proper_names): | |
'test creating strings' | |
assert flushdb | |
for name in proper_names: | |
StringTest(name).set(True) | |
assert len(get_redis_connection().keys()) == len(proper_names) | |
for name in proper_names: | |
assert StringTest(name).exists() | |
def test_expire(flushdb, proper_names): | |
'test expiring keys' | |
EXPIRES = 1 | |
assert flushdb | |
names = proper_names[:10] | |
for name in names: | |
st = StringTest(name) | |
st.setex(EXPIRES, True) | |
assert st.exists() | |
time.sleep(EXPIRES) | |
for name in names: | |
assert not StringTest(name).exists() | |
def test_set(flushdb, proper_names): | |
assert flushdb | |
model = RedisSet('settest', prefix='') | |
for name in proper_names: | |
model.sadd(name) | |
assert model.scard() == len(proper_names) | |
model.srem(*proper_names) | |
assert model.scard() == 0 | |
def test_sortedset(flushdb, proper_names): | |
assert flushdb | |
model = RedisSortedSet('sortedsettest', prefix='') | |
count = len(proper_names) | |
# add in reverse order | |
for i, name in enumerate(proper_names): | |
score = count - i | |
model.zadd(score, name) | |
assert model.zcard() == len(proper_names) | |
# read them back and reverse the list | |
names = model.zrange(0,-1) | |
names.reverse() | |
assert names == proper_names | |
def test_list(flushdb, proper_names): | |
assert flushdb | |
model = RedisList('listtest', prefix='') | |
count = len(proper_names) | |
# add from the tail, thus producing a list in the same order as | |
# proper_names | |
for name in proper_names: | |
model.rpush(name) | |
assert len(model) == len(proper_names) | |
# read them back and assert they're in the expected order | |
for i, name in enumerate(model): | |
assert name == proper_names[i] | |
def test_hashset(flushdb, proper_names): | |
assert flushdb | |
class HashTest(RedisHash): | |
pass | |
people = {} | |
for name in proper_names: | |
# create a "person" object, with attributes age and friend | |
age = random.randrange(20,60) | |
friends = random.sample(proper_names, random.randrange(5)) | |
attrs = dict(age=str(age), friends=','.join(friends)) | |
people[name] = attrs | |
HashTest(name).hmset(attrs) | |
# read back and compare | |
for name in proper_names: | |
assert HashTest(name).hgetall() == people[name] | |
except ImportError: | |
pass | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment