Source code for codequick.storage

# -*- coding: utf-8 -*-
from __future__ import absolute_import

# Standard Library Imports
from hashlib import sha1
import sqlite3
import time
import os

try:
    # noinspection PyPep8Naming
    import cPickle as pickle
except ImportError:  # pragma: no cover
    import pickle

# Package imports
from codequick.script import Script
from codequick.utils import ensure_unicode, PY3

if PY3:
    # noinspection PyUnresolvedReferences, PyCompatibility
    from collections.abc import MutableMapping, MutableSequence
    buffer = bytes
else:
    # noinspection PyUnresolvedReferences, PyCompatibility
    from collections import MutableMapping, MutableSequence

__all__ = ["PersistentDict", "PersistentList", "Cache"]

# The addon profile directory
profile_dir = Script.get_info("profile")


def check_filename(name):
    # Filename is already a fullpath
    if os.path.sep in name:
        filepath = ensure_unicode(name)
        data_dir = os.path.dirname(filepath)
    else:
        # Filename must be relative, joining profile directory with filename
        filepath = os.path.join(profile_dir, ensure_unicode(name))
        data_dir = profile_dir

    # Create any missing data directory
    if not os.path.exists(data_dir):
        os.makedirs(data_dir)

    # The full file path
    return filepath


class _PersistentBase(object):
    """
    Base class to handle persistent file handling.

    :param str name: Filename of persistence storage file.
    """

    def __init__(self, name):
        super(_PersistentBase, self).__init__()
        self._filepath = check_filename(name)
        self._version_string = "__codequick_storage_version__"
        self._data_string = "__codequick_storage_data__"
        self._serializer_obj = object
        self._stream = None
        self._hash = None
        self._data = None

    def _load(self):
        """Load in existing data from disk."""
        # Load storage file if exists
        if os.path.exists(self._filepath):
            self._stream = file_obj = open(self._filepath, "rb+")
            content = file_obj.read()

            # Calculate hash of current file content
            self._hash = sha1(content).hexdigest()

            # Load content and update storage
            return pickle.loads(content)

    def flush(self):
        """
        Synchronize data back to disk.

        Data will only be written to disk if content has changed.
        """

        # Serialize the storage data
        data = {self._version_string: 2, self._data_string: self._data}
        content = pickle.dumps(data, protocol=2)  # Protocol 2 is used for python2/3 compatibility
        current_hash = sha1(content).hexdigest()

        # Compare saved hash with current hash, to detect if content has changed
        if self._hash is None or self._hash != current_hash:
            # Check if FileObj Needs Creating First
            if self._stream:
                self._stream.seek(0)
                self._stream.truncate(0)
            else:
                self._stream = open(self._filepath, "wb+")

            # Dump data out to disk
            self._stream.write(content)
            self._hash = current_hash
            self._stream.flush()

    def close(self):
        """Flush content to disk & close file object."""
        self.flush()
        self._stream.close()
        self._stream = None

    def __enter__(self):
        return self

    def __exit__(self, *_):
        self.close()

    def __len__(self):
        return len(self._data)

    def __getitem__(self, index):
        return self._data[index][0]

    def __setitem__(self, index, value):
        self._data[index] = (value, time.time())

    def __delitem__(self, index):
        del self._data[index]

    def __bool__(self):
        return bool(self._data)

    def __nonzero__(self):
        return bool(self._data)


[docs]class PersistentDict(_PersistentBase, MutableMapping): """ Persistent storage with a :class:`dictionary<dict>` like interface. :param str name: Filename or path to storage file. :param int ttl: [opt] The amount of time in "seconds" that a value can be stored before it expires. .. note:: ``name`` can be a filename, or the full path to a file. The add-on profile directory will be the default location for files, unless a full path is given. .. note:: If the ``ttl`` parameter is given, "any" expired data will be removed on initialization. .. note:: This class is also designed as a "Context Manager". .. note:: Data will only be synced to disk when connection to file is "closed" or when "flush" method is explicitly called. :Example: >>> with PersistentDict("dictfile.pickle") as db: >>> db["testdata"] = "testvalue" >>> db.flush() """ def __iter__(self): return iter(self._data) def __repr__(self): return '%s(%r)' % (self.__class__.__name__, dict(self.items())) def __init__(self, name, ttl=None): super(PersistentDict, self).__init__(name) data = self._load() self._data = {} if data: version = data.get(self._version_string, 1) if version == 1: self._data = {key: (val, time.time()) for key, val in data.items()} else: data = data[self._data_string] if ttl: self._data = {key: item for key, item in data.items() if time.time() - item[1] < ttl} else: self._data = data
[docs] def items(self): return map(lambda x: (x[0], x[1][0]), self._data.items())
[docs]class PersistentList(_PersistentBase, MutableSequence): """ Persistent storage with a :class:`list<list>` like interface. :param str name: Filename or path to storage file. :param int ttl: [opt] The amount of time in "seconds" that a value can be stored before it expires. .. note:: ``name`` can be a filename, or the full path to a file. The add-on profile directory will be the default location for files, unless a full path is given. .. note:: If the ``ttl`` parameter is given, "any" expired data will be removed on initialization. .. note:: This class is also designed as a "Context Manager". .. note:: Data will only be synced to disk when connection to file is "closed" or when "flush" method is explicitly called. :Example: >>> with PersistentList("listfile.pickle") as db: >>> db.append("testvalue") >>> db.extend(["test1", "test2"]) >>> db.flush() """ def __repr__(self): return '%s(%r)' % (self.__class__.__name__, [val for val, _ in self._data]) def __init__(self, name, ttl=None): super(PersistentList, self).__init__(name) data = self._load() self._data = [] if data: if isinstance(data, list): self._data = [(val, time.time()) for val in data] else: data = data[self._data_string] if ttl: self._data = [item for item in data if time.time() - item[1] < ttl] else: self._data = data
[docs] def insert(self, index, value): self._data.insert(index, (value, time.time()))
[docs] def append(self, value): self._data.append((value, time.time()))
class Cache(object): """ Handle control of listitem cache. :param str name: Filename or path to storage file. :param int ttl: [opt] The amount of time in "seconds" that a cached session can be stored before it expires. .. note:: Any expired cache item will be removed on first access to that item. """ def __init__(self, name, ttl): self.filepath = check_filename(name) self.buffer = {} self.ttl = ttl self._connect() def _connect(self): """Connect to sqlite cache database""" self.db = db = sqlite3.connect(self.filepath, timeout=3) self.cur = cur = db.cursor() db.isolation_level = None # Create cache table cur.execute("CREATE TABLE IF NOT EXISTS itemcache (key TEXT PRIMARY KEY, value BLOB, timestamp INTEGER)") db.commit() def execute(self, sqlquery, args, repeat=False): # type: (str, tuple, bool) -> None self.cur.execute("BEGIN") try: self.cur.execute(sqlquery, args) # Handle database errors except sqlite3.DatabaseError as e: # Check if database is currupted if not repeat and os.path.exists(self.filepath) and \ (str(e).find("file is encrypted") > -1 or str(e).find("not a database") > -1): Script.log("Deleting broken database file: %s", (self.filepath,), lvl=Script.DEBUG) self.close() os.remove(self.filepath) self._connect() self.execute(sqlquery, args, repeat=True) else: raise e # Just roll back database on error and raise again except Exception as e: self.db.rollback() raise e else: self.db.commit() def __getitem__(self, key): if key in self.buffer: return self.buffer[key] else: item = self.cur.execute("SELECT value, timestamp FROM itemcache WHERE key = ?", (key,)).fetchone() if item is None: raise KeyError(key) else: value, timestamp = item if self.ttl > -1 and timestamp + self.ttl < time.time(): # Expired del self[key] raise KeyError(key) else: return pickle.loads(bytes(value)) def __setitem__(self, key, value): data = buffer(pickle.dumps(value)) self.execute("REPLACE INTO itemcache (key, value, timestamp) VALUES (?,?,?)", (key, data, time.time())) def __delitem__(self, key): self.execute("DELETE FROM itemcache WHERE key = ?", (key,)) def __contains__(self, key): try: if key in self.buffer: return True else: self.buffer[key] = self[key] return True except KeyError: return False def __enter__(self): return self def __exit__(self, *_): self.close() def close(self): self.db.close()