Skip to content

Commit

Permalink
Flesh out RedisDeque() code and tests (#36)
Browse files Browse the repository at this point in the history
* Run (slow) doctests on only CI

Make it faster/easier/cheaper to run through the entire unit test suite
while writing/debugging code locally.

* Implement sane RedisList.__eq__()

* Fix ResourceWarning lunacy

* Implement various RedisDeque methods

- `RedisDeque.append()`
- `RedisDeque.appendleft()`
- `RedisDeque.pop()`
- `RedisDeque.popleft()`

* Defensively import tests.base.run_doctests()

Before, I was naively `from tests.base import run_doctests`.  This
worked fine against source, but broke against the released package.

* Redis watch both self/other keys during __eq__()

Ensure that neither self nor other changes during an equality
comparison.  If either changes, then (implicitly) raise a WatchError.

* Factor out deceptive method name

`RedisList.__eq__()` looks recursive, but it isn't, because a slice of a
`RedisList` is a normal Python list (not a `RedisList`).

* Make equality testing safer

Explicitly avoid evaluating:
- `[]` as equal to `{}`, and
- `[0, 1]` as equal to `{0: 0, 1: 1}`

Also more cleanly separate mixin classes by behavior.

* Implement RedisDeque.rotate()

* Consistently name Python values vs. JSON values

* Remove gratuitous use of @Property

* More elegantly import tests.base.run_doctests()

* Bump version number
  • Loading branch information
brainix authored Jan 12, 2017
1 parent c4166d2 commit 454822d
Show file tree
Hide file tree
Showing 19 changed files with 340 additions and 167 deletions.
2 changes: 1 addition & 1 deletion pottery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@


__title__ = 'pottery'
__version__ = '0.36'
__version__ = '0.38'
__description__, __long_description__ = (
s.strip() for s in __doc__.split('\n\n', 1)
)
Expand Down
125 changes: 73 additions & 52 deletions pottery/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,16 @@



_default_url = os.environ.get('REDIS_URL', 'http://localhost:6379/')
_default_redis = Redis.from_url(_default_url)



class _Common:
_DEFAULT_REDIS_URL = 'http://localhost:6379/'
_NUM_TRIES = 3
_RANDOM_KEY_PREFIX = 'pottery:'
_RANDOM_KEY_LENGTH = 16

@staticmethod
def _encode(value):
encoded = json.dumps(value, sort_keys=True)
return encoded

@staticmethod
def _decode(value):
decoded = json.loads(value.decode('utf-8'))
return decoded

def __init__(self, *args, redis=None, key=None, **kwargs):
self.redis = redis
self.key = key
Expand All @@ -48,34 +42,13 @@ def __del__(self):
if self.key.startswith(self._RANDOM_KEY_PREFIX):
self.redis.delete(self.key)

def __eq__(self, other):
if type(self) is type(other) and \
self.redis == other.redis and \
self.key == other.key:
equals = True
else:
equals = super().__eq__(other)
if equals is NotImplemented:
equals = False
return equals

def __ne__(self, other):
does_not_equal = not self.__eq__(other)
return does_not_equal

@property
def _default_redis(self):
url = os.environ.get('REDIS_URL', self._DEFAULT_REDIS_URL)
redis = Redis.from_url(url)
return redis

@property
def redis(self):
return self._redis

@redis.setter
def redis(self, value):
self._redis = self._default_redis if value is None else value
self._redis = _default_redis if value is None else value

@property
def key(self):
Expand All @@ -98,6 +71,58 @@ def _random_key(self, *, tries=_NUM_TRIES):



class _Encodable:
@staticmethod
def _encode(value):
encoded = json.dumps(value, sort_keys=True)
return encoded

@staticmethod
def _decode(value):
decoded = json.loads(value.decode('utf-8'))
return decoded



class _Comparable(metaclass=abc.ABCMeta):
@abc.abstractproperty
def redis(self):
'Redis client.'

@abc.abstractproperty
def key(self):
'Redis key.'

def __eq__(self, other):
if self is other:
equals = True
elif isinstance(other, _Comparable) and \
self.redis == other.redis and \
self.key == other.key:
equals = True
else:
equals = super().__eq__(other)
if equals is NotImplemented:
equals = False
return equals



class _Clearable(metaclass=abc.ABCMeta):
@abc.abstractproperty
def redis(self):
'Redis client.'

@abc.abstractproperty
def key(self):
'Redis key.'

def clear(self):
'Remove the elements in a Redis-backed container. O(n)'
self.redis.delete(self.key)



class Pipelined(metaclass=abc.ABCMeta):
@abc.abstractproperty
def _NUM_TRIES(self):
Expand All @@ -111,7 +136,6 @@ def redis(self):
def key(self):
'Redis key.'

@property
@contextlib.contextmanager
def _pipeline(self):
pipeline = self.redis.pipeline()
Expand All @@ -120,13 +144,25 @@ def _pipeline(self):
finally:
pipeline.execute()

def _watch(func):
@contextlib.contextmanager
def _watch_context(self, *keys):
original_redis = self.redis
keys = keys or (self.key,)
try:
with self._pipeline() as pipeline:
self.redis = pipeline
self.redis.watch(*keys)
yield self.redis
finally:
self.redis = original_redis

def _watch_method(func):
@functools.wraps(func)
def wrap(self, *args, **kwargs):
for _ in range(self._NUM_TRIES):
try:
original_redis = self.redis
with self._pipeline as pipeline:
with self._pipeline() as pipeline:
self.redis = pipeline
self.redis.watch(self.key)
value = func(self, *args, **kwargs)
Expand All @@ -141,22 +177,7 @@ def wrap(self, *args, **kwargs):



class _Clearable(metaclass=abc.ABCMeta):
@abc.abstractproperty
def redis(self):
'Redis client.'

@abc.abstractproperty
def key(self):
'Redis key.'

def clear(self):
'Remove the elements in a Redis-backed container. O(n)'
self.redis.delete(self.key)



class Base(_Common, _Clearable, Pipelined):
class Base(_Common, _Encodable, _Comparable, _Clearable, Pipelined):
...


Expand Down
21 changes: 12 additions & 9 deletions pottery/contexttimer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@



import contextlib
import timeit

from tests.base import run_doctests



class ContextTimer:
Expand All @@ -25,21 +24,21 @@ class ContextTimer:
>>> timer = ContextTimer()
>>> timer.start()
>>> time.sleep(0.1)
>>> 100 <= timer.elapsed < 200
>>> 100 <= timer.elapsed() < 200
True
>>> timer.stop()
>>> time.sleep(0.1)
>>> 100 <= timer.elapsed < 200
>>> 100 <= timer.elapsed() < 200
True
...or as a context manager:
>>> tests = []
>>> with ContextTimer() as timer:
... time.sleep(0.1)
... tests.append(100 <= timer.elapsed < 200)
... tests.append(100 <= timer.elapsed() < 200)
>>> time.sleep(0.1)
>>> tests.append(100 <= timer.elapsed < 200)
>>> tests.append(100 <= timer.elapsed() < 200)
>>> tests
[True, True]
'''
Expand Down Expand Up @@ -71,7 +70,6 @@ def stop(self):
else:
raise RuntimeError("timer hasn't yet been started")

@property
def elapsed(self):
try:
value = (self._stopped or timeit.default_timer()) - self._started
Expand All @@ -84,5 +82,10 @@ def elapsed(self):


if __name__ == '__main__': # pragma: no cover
# Run the doctests in this module with: $ python3 -m pottery.redlock
run_doctests()
# Run the doctests in this module with:
# $ source venv/bin/activate
# $ python3 -m pottery.contexttimer
# $ deactivate
with contextlib.suppress(ImportError):
from tests.base import run_doctests
run_doctests()
6 changes: 3 additions & 3 deletions pottery/counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class RedisCounter(RedisDict, collections.Counter):

# Method overrides:

@Pipelined._watch
@Pipelined._watch_method
def _update(self, iterable=tuple(), *, sign=+1, **kwargs):
to_set = {}
try:
Expand Down Expand Up @@ -108,7 +108,7 @@ def __neg__(self):
modifier_func=lambda x: -x,
)

@Pipelined._watch
@Pipelined._watch_method
def _imath_op(self, other, *, sign=+1):
to_set = {k: self[k] + sign * v for k, v in other.items()}
to_del = [k for k, v in to_set.items() if v <= 0]
Expand All @@ -132,7 +132,7 @@ def __isub__(self, other):
'Same as __sub__(), but in-place. O(n)'
return self._imath_op(other, sign=-1)

@Pipelined._watch
@Pipelined._watch_method
def _iset_op(self, other, *, func):
to_set, to_del = {}, []
for k in itertools.chain(self, other):
Expand Down
37 changes: 35 additions & 2 deletions pottery/deque.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import collections
import itertools

from .base import Pipelined
from .list import RedisList


Expand All @@ -28,5 +29,37 @@ def __init__(self, iterable=tuple(), maxlen=None, *, redis=None, key=None):
def maxlen(self):
return self._maxlen

def insert(*args, **kwargs):
raise NotImplementedError
def append(self, value):
'Add an element to the right side of the RedisDeque.'
self.redis.rpush(self.key, self._encode(value))

def appendleft(self, value):
'Add an element to the left side of the RedisDeque.'
self.redis.lpush(self.key, self._encode(value))

def pop(self):
encoded_value = self.redis.rpop(self.key)
if encoded_value is None:
raise IndexError('pop from an empty {}'.format(self.__class__.__name__))
else:
return self._decode(encoded_value)

def popleft(self):
encoded_value = self.redis.lpop(self.key)
if encoded_value is None:
raise IndexError('pop from an empty {}'.format(self.__class__.__name__))
else:
return self._decode(encoded_value)

@Pipelined._watch_method
def rotate(self, n=1):
'Rotate the RedisDeque n steps to the right (default n=1). If n is negative, rotates left.'
if n:
push_method = 'lpush' if n > 0 else 'rpush'
values = self[-n:] if n > 0 else self[:-n]
encoded_values = [self._encode(element) for element in values]
trim_indices = (0, len(self)-n) if n > 0 else (-n, len(self))

self.redis.multi()
getattr(self.redis, push_method)(self.key, *encoded_values)
self.redis.ltrim(self.key, *trim_indices)
8 changes: 4 additions & 4 deletions pottery/dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ def __init__(self, iterable=tuple(), *, redis=None, key=None, **kwargs):

def __getitem__(self, key):
'd.__getitem__(key) <==> d[key]. O(1)'
value = self.redis.hget(self.key, self._encode(key))
if value is None:
encoded_value = self.redis.hget(self.key, self._encode(key))
if encoded_value is None:
raise KeyError(key)
else:
return self._decode(value)
return self._decode(encoded_value)

def __setitem__(self, key, value):
'd.__setitem__(key, value) <==> d[key] = value. O(1)'
Expand Down Expand Up @@ -67,7 +67,7 @@ def __repr__(self):
# Method overrides:

# From collections.abc.MutableMapping:
@Pipelined._watch
@Pipelined._watch_method
def update(self, iterable=tuple(), **kwargs):
to_set = {}
with contextlib.suppress(AttributeError):
Expand Down
Loading

0 comments on commit 454822d

Please sign in to comment.