import errno import os import time from collections import namedtuple from contextlib import contextmanager from string import zfill def mkdir_p(path): try: os.makedirs(path) except OSError as exc: # Python >2.5 if exc.errno == errno.EEXIST and os.path.isdir(path): pass else: raise CacheKey = namedtuple('CacheKey', 'coord tile_size layers fmt') def clean_empty_parent_dirs(path, parent_dir=None): """ Starting from a file or directory ``path``, recursively delete empty parent directories until a non-empty directory or ``parent_dir`` is reached. This is like ``os.removedirs()`` but with a specified stop point. """ if not os.path.exists(path): return if not os.path.isdir(path): path = os.path.dirname(path) while True: if parent_dir and path.endswith(parent_dir): return try: os.rmdir(path) path = os.path.dirname(path) except OSError: return class LockTimeout(BaseException): pass class BaseCache(object): def obtain_lock(self, cache_key, **kwargs): raise NotImplemented() def release_lock(self, cache_key): raise NotImplemented() def set(self, cache_key, data): raise NotImplemented() def get(self, cache_key): raise NotImplemented() @contextmanager def lock(self, cache_key, **kwargs): self.obtain_lock(cache_key, **kwargs) try: yield self finally: self.release_lock(cache_key) class NullCache(BaseCache): def obtain_lock(self, cache_key, **kwargs): return def release_lock(self, cache_key): return def set(self, cache_key, data): return def get(self, cache_key): return None class RedisCache(BaseCache): def __init__(self, redis_client, **kwargs): self.client = redis_client self.timeout = kwargs.get('timeout') or 10 self.key_prefix = kwargs.get('key_prefix') or 'tiles' self.expires = kwargs.get('expires') def _generate_key(self, key_type, cache_key): return '{}.{}.{}-{}-{}-{}-{}-{}'.format( self.key_prefix, key_type, cache_key.tile_size, cache_key.layers, cache_key.fmt.extension, cache_key.coord.zoom, cache_key.coord.column, cache_key.coord.row, ) def obtain_lock(self, cache_key, **kwargs): """ Obtains a lock based on the given tile coordinate. By default, it will wait/block ``timeout`` seconds before giving up and throwing a ``LockTimeout`` exception. :param coord The tile Coordinate to lock on. :param expires Any existing lock older than ``expires`` seconds will be considered invalid. :param timeout If another client has already obtained the lock for this tile, sleep for a maximum of ``timeout`` seconds before giving up and throwing a ``LockTimeout`` exception. A value of 0 means to never wait. (https://chris-lamb.co.uk/posts/distributing-locking-python-and-redis) """ key = self._generate_key('lock', cache_key) expires = kwargs.get('expires', 60) timeout = kwargs.get('timeout', 10) while timeout >= 0: expire_tstamp = time.time() + expires + 1 if self.client.setnx(key, expires): # We gained the lock return current_value = self.client.get(key) # We found an expired lock and nobody raced us to replacing it if current_value and float(current_value) < time.time() and \ self.client.getset(key, expire_tstamp) == current_value: return timeout -= 1 time.sleep(1) raise LockTimeout("Timeout whilst waiting for a lock") def release_lock(self, cache_key): key = self._generate_key('lock', cache_key) self.client.delete(key) def set(self, cache_key, data): key = self._generate_key('data', cache_key) self.client.set(key, data, ex=self.expires) def get(self, cache_key): key = self._generate_key('data', cache_key) return self.client.get(key) class FileCache(BaseCache): def __init__(self, file_prefix, **kwargs): self.prefix = file_prefix def _generate_key(self, key_type, cache_key): x_fill = zfill(cache_key.coord.column, 9) y_fill = zfill(cache_key.coord.row, 9) return os.path.join( self.prefix, str(cache_key.tile_size), cache_key.layers, zfill(cache_key.coord.zoom, 2), x_fill[0:3], x_fill[3:6], x_fill[6:9], y_fill[0:3], y_fill[3:6], '{}.{}.{}'.format(y_fill[6:9], cache_key.fmt.extension, key_type), ) def _acquire(self, key): try: with open(key, 'r'): return False except IOError: directory = os.path.dirname(key) mkdir_p(directory) with open(key, 'w'): return True def obtain_lock(self, cache_key, **kwargs): """ Obtains a lock based on the given tile coordinate. By default, it will wait/block ``timeout`` seconds before giving up and throwing a ``LockTimeout`` exception. :param coord The tile Coordinate to lock on. :param expires Any existing lock older than ``expires`` seconds will be considered invalid. :param timeout If another client has already obtained the lock for this tile, sleep for a maximum of ``timeout`` seconds before giving up and throwing a ``LockTimeout`` exception. A value of 0 means to never wait. """ key = self._generate_key('lock', cache_key) expires = kwargs.get('expires', 60) timeout = kwargs.get('timeout', 10) while timeout >= 0: expires = time.time() + expires + 1 if self._acquire(key): # We gained the lock return timeout -= 1 time.sleep(1) raise LockTimeout("Timeout whilst waiting for a lock") def release_lock(self, cache_key): key = self._generate_key('lock', cache_key) try: os.remove(key) except OSError as e: # errno.ENOENT = no such file or directory if e.errno != errno.ENOENT: # re-raise exception if a different error occurred raise def set(self, cache_key, data): key = self._generate_key('data', cache_key) directory = os.path.dirname(key) mkdir_p(directory) with open(key, 'w') as f: f.write(data) def get(self, cache_key): key = self._generate_key('data', cache_key) try: with open(key, 'r') as f: return f.read() except IOError: return None