# @GPL_HEADER_START
# Use of the RAVE and related source code is subject to the terms
# of the following licenses:
# 
# GNU Public License (GPL) Rights pursuant to Version 2, June 1991
# Government Purpose License Rights (GPLR) pursuant to DFARS 252.225-7013
# 
# NO WARRANTY
# 
# ANY INFORMATION, MATERIALS, SERVICES, INTELLECTUAL PROPERTY OR OTHER
# PROPERTY OR RIGHTS GRANTED OR PROVIDED BY CARNEGIE MELLON UNIVERSITY
# PURSUANT TO THIS LICENSE (HEREINAFTER THE "DELIVERABLES") ARE ON AN
# "AS-IS" BASIS. CARNEGIE MELLON UNIVERSITY MAKES NO WARRANTIES OF ANY
# KIND, EITHER EXPRESS OR IMPLIED AS TO ANY MATTER INCLUDING, BUT NOT
# LIMITED TO, WARRANTY OF FITNESS FOR A PARTICULAR PURPOSE,
# MERCHANTABILITY, INFORMATIONAL CONTENT, NONINFRINGEMENT, OR ERROR-FREE
# OPERATION. CARNEGIE MELLON UNIVERSITY SHALL NOT BE LIABLE FOR INDIRECT,
# SPECIAL OR CONSEQUENTIAL DAMAGES, SUCH AS LOSS OF PROFITS OR INABILITY
# TO USE SAID INTELLECTUAL PROPERTY, UNDER THIS LICENSE, REGARDLESS OF
# WHETHER SUCH PARTY WAS AWARE OF THE POSSIBILITY OF SUCH DAMAGES.
# LICENSEE AGREES THAT IT WILL NOT MAKE ANY WARRANTY ON BEHALF OF
# CARNEGIE MELLON UNIVERSITY, EXPRESS OR IMPLIED, TO ANY PERSON
# CONCERNING THE APPLICATION OF OR THE RESULTS TO BE OBTAINED WITH THE
# DELIVERABLES UNDER THIS LICENSE.
# 
# Licensee hereby agrees to defend, indemnify, and hold harmless Carnegie
# Mellon University, its trustees, officers, employees, and agents from
# all claims or demands made against them (and any related losses,
# expenses, or attorney's fees) arising out of, or relating to Licensee's
# and/or its sub licensees' negligent use or willful misuse of or
# negligent conduct or willful misconduct regarding the Software,
# facilities, or other rights or assistance granted by Carnegie Mellon
# University under this License, including, but not limited to, any
# claims of product liability, personal injury, death, damage to
# property, or violation of any laws or regulations.
# 
# Carnegie Mellon University Software Engineering Institute authored
# documents are sponsored by the U.S. Department of Defense under
# Contract F19628-00-C-0003. Carnegie Mellon University retains
# copyrights in all material produced under this contract. The U.S.
# Government retains a non-exclusive, royalty-free license to publish or
# reproduce these documents, or allow others to do so, for U.S.
# Government purposes only pursuant to the copyright license under the
# contract clause at 252.227.7013.
# 
# @GPL_HEADER_END
import pdb
import time
import cPickle as pickle
#import pickle
import threading
import os
import os.path
import errno
import types
import shutil
import random
import copy
import md5
import struct
import sys
import tempfile
import codecs


import rave.log as rlog

get_log = rlog.log_factory("rave.cache.core")


# How old a cache entry must be after it's expired before
# it is purged from the cache. (Five minutes, at the moment.)
interval_of_death = 300

import rave.exceptions as rexcept
import rave.util as rutil
import rave.util.json as rjson

# Trace filesystem operations
from rave.trace import trace_open, trace_close, \
     trace_purge, trace_fdopen, trace_move, trace_method_call



# TODO: filenames never reused (DONE (not checked))
# TODO: If you see a metafile that doesn't have all the attributes
#       you expect, ignore it

# Set to False to definitively disallow unscheduled cleanup
ALLOW_OPPORTUNISTIC_CLEANUP=True

thread_id_counter = 0
thread_id_lock = threading.Lock()

class LocalStorage(threading.local):
    def __init__(self):
        global thread_id_counter
        thread_id_lock.acquire()
        thread_id_counter += 1
        self.thread_id = thread_id_counter
        thread_id_lock.release()
    #   The time (in seconds) when this thread granted its last UID 
        self.last_uid_granted = None
    #   When multiple uids are generated in the same second, this
    #   counter is used to preserve uniqueness
        self.uid_counter = 0

local_storage = LocalStorage()

#
# Utility functions
#

# Get all a user's groups (primary and supplementary) in one list
def getallgroups():
    return [os.getgid()] + os.getgroups()

# Remove a file if it exists, ignoring missing files
def purge(fname):
    try:
        # Make sure log reports caller of purge, not purge
        trace_purge(fname, tracelvl=2)
    except (OSError, IOError), e:
        if e.errno == errno.ENOENT:
            pass


# A set of classes representing the various rules and types of
# filenames in the cache, primarily to capture the naming scheme logic
# in one place.


class CachePaths(object):
    """Logic related to the internal directory structure of a cache.

    There are two types of directories in a cache. The working
    directory contains lockfiles and work files.  All remaining
    files in the directory comprise a tree of buckets, based on the
    16-digit hexadecimal MD5 digest uniquely identifying each bucket.
    Buckets map to MD5 digests thusly:

        AABB/CCDD/<filename>

    For example, a file with the MD5 digest "0123456789ABCDEF" would be 
    stored in the repository root in the path 0123/4567. (For details on 
    how the file is named, see CacheFile.)
    """
    work_dirname = "temporary-files"
    config_filename = "cache.cfg"
    @classmethod
    def dirs(cls, hash):
        "Convert a hash into a directory path in the cache."
        #return [hash[:2], hash[2:4], hash[4:6], hash[6:8]]
        return [hash[:2], hash[2:4]]
    @classmethod
    def bucket_path(cls, hash, root=''):
        bucket_dirs = os.path.join(*cls.dirs(hash))
        return os.path.join(root, bucket_dirs)
    @classmethod
    def lockfile(cls, repository):
        dhash = md5.new(repository).hexdigest()
        return "/tmp/rave-cache-lockfile-%s" % dhash
        #path, dname = os.path.split(repository)
        #return os.path.join(path, ".%s.locked" % dname)
    

class CacheFile(object):
    """Logic related to the names of cache files.

    There are several types of files in the cache:

        * Work files are where analyses write their data before it is
          cached.
        * Data files are cached analysis data.
        * Metadata files contain information about the corresponding
          data file, such as information about the analysis and arguments
          that generated it.

    Corresponding data and metadata files are referred to collectively as
    a cache entry.

    Except for work files (see below), all are named in the following form:

        [hash].[uid].[ext]

    where:

        [hash] == a 16-character hexadecimal hash of the unique
            features of the entry
        [uid]  == a unique identifier for the entry (in case of
            hash collisions)
        [ext]  == a file extension. See individual file classes
            for addition extension information.
"""
    @classmethod
    def matches(cls, fname):
        return fname.endswith(".%s" % cls.extension)
    @classmethod
    def generate(cls, digest, uid, path=None):
        if path:
            return os.path.join(
                path, "%s-%s.%s" % (digest, uid, cls.extension))
        else:
            return "%s-%s.%s" % (digest, uid, cls.extension)
    def _crack_fname(self, fname):
        digest, therest = fname.split("-")
        uid, ext = fname.split(".")
        return digest, uid, ext
    @classmethod
    def uid(cls, fname):
        if fname is None:
            return None
        else:
            return self._crack_fname(fname)[-2]
    @classmethod
    def digest(cls, fname):
        if fname is None:
            return None
        else:
            return self._crack_fname(os.path.basename(fname))[-3]
    @classmethod
    def fullname(cls, hash, uid, root=''):
        return os.path.join(
            CachePaths.bucket_path(hash, root),  cls.generate(hash, uid))
    # TODO: Inline
    @classmethod
    def convert(cls, fname, newcls):
        "Convert between file types. Path information is preserved."
        head, tail = os.path.split(fname)
        return newcls.generate(cls.digest(fname), cls.uid(fname), head)
    @classmethod
    def to_meta(cls, fname):
        return cls.convert(fname, MetaFile)
    @classmethod
    def to_data(cls, fname):
        return cls.convert(fname, DataFile)

class MetaFile(CacheFile):
    "Cache metadata file logic."
    extension="meta"

class DataFile(CacheFile):
    "Cache data file logic."
    extension="dat"





class CallInfo(object):
    "Information about an Operation call"
    @classmethod
    def from_op(cls, op, kwargs):
        "Generate CallInfo object from Operation."
        return cls(
            op.core_name(), op.core_filename(), op.version, kwargs)
    @classmethod
    def from_data(cls, data):
        "Generate CallInfo object from exported data."
        return cls(data['name'], data['file'], data['version'], data['args'])
    def __init__(self, name, file, version, norm_kwargs):
        self.op_name = name
        self.op_file = file
        self.op_version = version
        self.args = norm_kwargs
        self.key_json = rjson.unparse(
            ((self.op_name, self.op_file, self.op_version), self.args))
    def __str__(self):
        return "<CallInfo: %s(%s)>" % (self.op_name, self.args)
    def key_material(self):
        return self.key_json
    def digest(self):
        "Return a hashed version of the cache key."
        key = str(self.key_json)
        return md5.new(key).hexdigest()
    def __eq__(self, other):
        return self.key_json == other.key_json 
    def as_data(self):
        "Export data required to recreate this CallInfo."
        return {
              'name'        :  self.op_name
            , 'file'        :  self.op_file
            , 'version'     :  self.op_version
            , 'args':  self.args
        }

# Constants reflecting special cases for when the results
# of an operation should be considered out-of-date
NOW   = 0
NEVER = -1

class CacheMeta(object):
#   Version of the on-disk format for CacheMetas
    format_version = 1

#   The file extension for cache metadata files
    @classmethod
    def load(cls, repository, fname):
        f = trace_open(fname, 'rb')
        try:
            params = rjson.parse(f)
        finally:
            trace_close(f)
        params['repository'] = repository
        return cls(
            CallInfo.from_data(params['callinfo']), 
            params['expires'], params['mime_type'],
            repository, params['created'], params['uid'],
            params['meta_version']
        )
    def save(self, repository):
        "Save metadata file to disk. Path is assumed to already exist."
        if self.uid is None:
            raise rexcept.CacheError(
                "Cannot save CacheMeta without a UID")
        data = {
              'uid'      : self.uid
            , 'callinfo' : self.callinfo.as_data()
            , 'mime_type': self.mime_type
            , 'created'  : self.created_timestamp
            , 'expires'  : self.expires
            , 'meta_version'  : self.meta_version
        }
        utf8 = codecs.getwriter("utf8")
    #   This file needs to appear atomically in the cache, so
    #   populate it in a temp file and move it over.
        fd, fname = tempfile.mkstemp(prefix='tmpmeta-', 
                                     dir=repository.work_dir())
        f = utf8(trace_fdopen(fd, 'wb', name=fname))
        try:
            rjson.unparse(data, f)
        finally:
            trace_close(f)
        trace_move(fname, self.file_name())
        os.chmod(self.file_name(), 0664)
        os.chown(self.file_name(),
                 os.getuid(), self.repository.group)
    def __init__(self, callinfo, expires, mime_type,
                 repository, created, uid, version=None):
        self.callinfo = callinfo
        self.expires = expires 
        self.mime_type = mime_type
        self.repository = repository
        self.created_timestamp = created
        self.uid = uid
        if version is not None:
            self.meta_version = version
        else:
            self.meta_version = self.format_version

    def __str__(self):
        def timefmt(t):
            if t == NOW:
                return "NOW"
            elif t == NEVER:
                return "NEVER"
            else:
                ftime = time.strftime(
                      "%Y-%m-%dT%H:%M:%S", time.localtime(t)) 
                msecs = (t - long(t)) * 1000
                return "%s,%03d" % (ftime, msecs)
            
        return "<CacheMeta: %s->%s; %s>" % (timefmt(self.created_timestamp),
                                           timefmt(self.expires), self.callinfo)
    def file_name(self):
        return MetaFile.fullname(
            self.callinfo.digest(), self.uid, self.repository.root_dir())
    def data_file(self, relative=False):
        fname = DataFile.fullname(
            self.callinfo.digest(), self.uid, self.repository.root_dir())
        if relative:
            return self.repository.strip_root_dir(fname)
        else:
            return fname
    def expired(self):
        if self.expires == NEVER:
            return False
        return self.expires <= time.time()
    def ancient(self):
        if self.expires == NEVER:
            return False
        return self.expires + interval_of_death <= time.time()
        

class Repository(object):
    "The physicial cache repository on disk."
    def _validate_cache(self, warn_empty):
    #   Validate the prospective site of a cache.
        if not os.path.exists(self.root_dir()):
        #   Cache root does not exist. Definitely new cache.
            return None
        if not os.path.isdir(self.root_dir()):
            raise rexcept.ConfigurationError(
                "A file named %s already exists, but is not a "
                "directory. Cannot create cache directory" %
                self.root_dir()
            )
        if len(os.listdir(self.root_dir())) == 0:
        #   An empty directory, possibly created for this purpose
            if warn_empty:
                get_log().warn(
                    "Specified cache directory is empty: "
                    "creating cache anyway")
            return None
        has_working_dir = (os.path.exists(self.work_dir()) and
                           os.path.isdir(self.work_dir()))
        has_cache_config = os.path.exists(self.cache_config_file())
        if not has_working_dir or not has_cache_config:
            raise rexcept.IncompleteCacheError(self.root_dir())
        try:
            f = open(self.cache_config_file(), 'rb')
            try:
                cache_params = rjson.parse(f)
            finally:
                f.close()
            cache_gid = cache_params['group']
        #   User must be in the group used to write to the cache.
        #   NOTE: Unix-specific
            if cache_gid not in getallgroups():
                raise rexcept.ConfigurationError(
                   "You are not in the group (%d) used to write to "
                    "this cache." % cache_gid
                )
            return cache_params
        except rexcept.RaveException:
            raise
        except Exception, e:
            if hasattr(e, 'errno'):
                raise rexcept.IncompleteCacheError(
                    self.root_dir(), os.strerror(e.errno))
            raise rexcept.IncompleteCacheError(self.root_dir(), str(e))
    def _lock_cachedir(self):
        lockname = CachePaths.lockfile(self.root_dir())
        timeout = time.time() + 2
        wait_time = time.time()
        while wait_time < timeout:
            try:
                fd = os.open(lockname, (os.O_CREAT | os.O_EXCL))
                os.close(fd)
                return
            except OSError, e:
                if e.errno == errno.EEXIST:
                    time.sleep(.25)
                    wait_time = time.time()
                else:
                    raise
    #   Timeout waiting for lockfile. Nothing we do with the lock should
    #   last nearly as long as 2 seconds, so it's probably a stale lockfile.
        raise rexcept.CacheError(
            "Timeout waiting for cache lock. Perhaps a stale lockfile at %s?"
            % lockname
        )
    def _unlock_cachedir(self):
        os.remove(CachePaths.lockfile(self.root_dir()))
    def _create_new_cache(self):
    #   Create a new cache, possibly over an incomplete or preexisting one.
        for d in (self.root_dir(), self.work_dir()):
            try:
                os.mkdir(d)
                os.chown(d, os.getuid(), self.group)
                os.chmod(d, 0775)
            except OSError, e:
                if e.errno != errno.EEXIST:
                    raise
        utf8 = codecs.getwriter("utf8")
        f = utf8(open(self.cache_config_file(), 'wb'))
        try:
            data = { 'group': self.group, 'version': 1 }
            rjson.unparse(data, f)
        finally:
            f.close()
        os.chmod(self.cache_config_file(), 0664)
        os.chown(self.cache_config_file(),
                 os.getuid(), self.group)
    def __init__(self, rootdir, warn_on_empty_repository, group):
        self.root_dirname = rootdir
        start_time = time.time()
        self._lock_cachedir()
        try:
            cache_params = self._validate_cache(warn_on_empty_repository)
            if cache_params is None:
                if group not in getallgroups():
                    raise rexcept.ConfigurationError(
                        "User is not a member of group %d" % group
                    )
                self.group = group
                self._create_new_cache()
            else:
                self.group = cache_params['group']
        finally:
            self._unlock_cachedir()
    def root_dir(self):
        return self.root_dirname
    def work_dir(self):
        return os.path.join(self.root_dir(), CachePaths.work_dirname)
    def cache_config_file(self):
        return os.path.join(self.root_dir(), CachePaths.config_filename)
    def all_metas(self):
        "Generator for cache metadata on all files in repository."
        for dpath, dnames, fnames in os.walk(self.root_dir()):
        #   Skip work directories
            if dpath == self.root_dir():
                dnames[:] = [d for d in dnames if d != self.work_dir()]
            for f in fnames:
                if MetaFile.matches(f):
                    try:
                        meta = CacheMeta.load(self, os.path.join(dpath, f))
                    except (OSError, IOError), e:
                        if e.errno == errno.ENOENT:
                            get_log().debug(
                                "Can't purge %s, it's already gone (%s)",
                                os.path.join(dpath, f), str(e))
                            continue
                    yield meta
    def ancient_metas(self):
        for m in self.all_metas():
            if m.ancient():
                yield m
            else:
                continue
#     def ancient_metas(self):
#         "Generator for cache metadata on all purgeable files in repository."
#         for dpath, dnames, fnames in os.walk(self.root_dir()):
#         #   Skip work directories
#             if dpath == self.root_dir():
#                 dnames[:] = [d for d in dnames if d != self.work_dir()]
#             for f in fnames:
#                 if MetaFile.matches(f):
#                     try:
#                         meta = CacheMeta.load(self, os.path.join(dpath, f))
#                     except (OSError, IOError), e:
#                         if e.errno == errno.ENOENT:
#                             get_log().debug(
#                                 "Can't purge %s, it's already gone (%s)",
#                                 os.path.join(dpath, f), str(e))
#                             continue
#                     if meta.ancient():
#                         yield meta
    def strip_root_dir(self, path):
    #   Return the root_dir from the path
        if path[:len(self.root_dir())] == self.root_dir():
            return path[len(self.root_dir()):]
        else:
            return path
            

class Bucket(object):
    """Conceptually, a collection of entries in the cache, grouped by
    the hash of their cache key.

    A Bucket contains information on how the entries in the bucket are
    named, both in the working directory and the cache
    repository. Operations on the bucket manage the creation (in
    working space), transition (to the cache proper), location and
    removal of entries in the cache.

    A Bucket is an organizing unit, not a true collection.
    Consequently, they are not persistent, either on disk or in
    memory. To use the Bucket containing a given entry, create it on
    the fly.

    Most Buckets will "contain" exactly one cache entry; in the event of hash 
    collisions, they will contain more than one."""
    
    def __init__(self, repository, digest):
        self.repository = repository
        self.digest = digest
    def __str__(self):
        return "<Bucket %s>" % self.digest
    def dirs(self):
        return CachePaths.dirs(self.digest)
    def path(self):
        return CachePaths.bucket_path(
            self.digest, self.repository.root_dir())
    def data_fullname(self, uid):
        return DataFile.fullname(
            self.digest, uid, self.repository.root_dir())
    def meta_fullname(self, uid):
        return MetaFile.fullname(
            self.digest, uid, self.repository.root_dir())
    def work_fullname(self, uid):
        return os.path.join(
            self.repository.work_dir(),
            "%s-%s" % (self.digest, uid)
        )
    def metafiles(self):
    #   Return the metafiles in this bucket
        return [os.path.join(self.path(), f) 
                for f in os.listdir(self.path())
                if f.startswith(self.digest) and MetaFile.matches(f)]
    def create_uid(self):
        uid_time = int(time.time())
        if uid_time == local_storage.last_uid_granted:
            local_storage.uid_counter += 1
        else:
            local_storage.uid_counter = 0
            local_storage.last_uid_granted = uid_time
        return "%.4X%.2X%.8X%.2X" % (
            os.getpid(), local_storage.thread_id,
            uid_time, local_storage.uid_counter)
    def create_dirs(self):
        path = self.repository.root_dir()
        for d in self.dirs():
            path = os.path.join(path, d)
            try:
                os.mkdir(path)
                os.chown(path, os.getuid(),
                         self.repository.group)
                os.chmod(path, 0775)
            except OSError, e:
                if e.errno != errno.EEXIST:
                    raise
    def find_entries(self, callinfo):
        if os.path.exists(self.path()):
            for candidate in self.metafiles():
                try:
                    meta = CacheMeta.load(self.repository, candidate)
                except (OSError, IOError), e:
                    if e.errno == errno.ENOENT:
                        get_log().debug("Ignoring %s (deleted, probably "
                                        "by another process)", candidate)
                    continue
                if meta.callinfo == callinfo:
                    yield meta
    def deploy_entry(self, meta, workfile):
        try:
            self.create_dirs()
            os.chmod(workfile, 0664)
            os.chown(workfile, os.getuid(), self.repository.group)
            trace_move(workfile, meta.data_file())
        except:
            purge(workfile)
            raise
        try:
            meta.save(self.repository)
        except:
            purge(meta.data_file())
            raise
    def purge_entry(self, meta):
        l = get_log("cleanup")
        l.debug("purging %s", meta)
        purge(meta.file_name())
        purge(meta.data_file())
            

# REMOVING THIS FUNCTIONALITY:
#   When multiple processes use the same cache, there's
#   the possibility of a race condition where one process
#   is ready to write into a directory that gets removed
#   as part of a purge process. To avoid introducing locks
#   between processes, we will instead never delete
#   directories.
#        dirs = [self.repository.root_dir()] + self.dirs()
#        l.debug("removing %s", os.path.join(*dirs))
#    #   TODO: only trap ENOENT and maybe ENOTEMPTY
#        try:
#            os.removedirs(os.path.join(*dirs))
#        except:
#            pass

#        except OSError, e:
#            if e.errno == errno.ENOTEMPTY:
#                pass # Ignore failure
#            else:
#                raise
#

        

class CachePutTransaction(object):
    """
    A set of changes to a cache culminating in a new item being
    put into the cache.
    """
    def __init__(self, cache, callinfo, mime_type, bucket):
        self.cache = cache
        self.callinfo = callinfo
        self.mime_type = mime_type
        self.bucket = bucket
        self.uid = bucket.create_uid()
        self.workfile = bucket.work_fullname(self.uid)
        # Will be replaced by the CacheMeta if it's created
        self.meta = None
    def __str__(self):
        return "<CachePutTransaction: %s>" % self.callinfo
    def filename(self):
        return self.workfile
    def new_filename(self, relative=False):
        fname = DataFile.fullname(self.callinfo.digest(), self.uid,
                                 self.cache.repository.root_dir())
        if relative:
            return self.cache.repository.strip_root_dir(fname)
        else:
            return fname
    def open(self):
        "Open the working file (which will be a new cache item) for writing."
        return trace_open(self.workfile, 'w')
    def commit(self, expires):
        "Move the new cache item into its place in the repository."
        try:
            long(expires)
        except TypeError:
            raise rexcept.CacheError(
                "expiration time must be number; got %s (%s)" %
                (type(expires), expires))
        created = time.time()
        self.meta = CacheMeta(self.callinfo, expires, self.mime_type,
                         self.cache.repository, created, self.uid)
        self.bucket.deploy_entry(self.meta, self.workfile)
        return self.meta
    def rollback(self):
        if self.meta is not None:
            self.bucket.purge_entry(self.meta)
        purge(self.workfile)




class Cache(object):
    "Base class for caches that back RAVE decorators"
    def __init__(self, repository, scheduler=None, purge_every=0, strat=None,
                 group=None, opportunistic_cleanup=None,
                 warn_on_empty_repository=True):
        """
        Constructor
        Parameters:
        repository: str
            path -- where the cached files live. The cache will attempt to
            create this path if it doesn't exist. If it does exist and is empty,
            this function will log a warning (see warn_on_empty_repository).
            If the directory exists and is not empty, but doesn't already
            contain the file referenced in save_to, this function will
            raise an exception.
        scheduler: rave.threads.Scheduler
            Use this Scheduler object to schedule cache maintenance tasks
            (like pruning and reaping). The cache does not take over
            responsibility for maintaining the scheduler -- that remains
            the responsibility of the calling code.

            If no scheduler is provided, the cache will occasionally be
            cleaned up opportunistically when a get or put occurs, unless
            opportunistic_cleanup is set to False.
        purge_every: int
            How often to reap expired cache entries from the file store. 
            (seconds) If no scheduler is provided, this argument is ignored.
        strat: function
            Expiration strategy to use by default when one isn't supplied.
            If strat is None, a CacheStrategy must be supplied or an
            error results.
        group: int
            GID to use when writing files to the cache. By default, the
            user's primary group is used.
        opportunistic_cleanup: boolean | None
            The cache will clean out expired entries occasionally prior to 
            running a get or put operation if a scheduler isn't supplied. 
            Setting this option to True enables this behavior 
            unconditionally; setting it to False disables it unconditionally.
        warn_on_empty_repository: boolean
            If True and the repository directory already exists and is empty,
            this function will send a warning to the log. To disable this
            warning, set warn_on_empty_repository to False.
        """
        trace_method_call(self)
        if group is None:
            group = getallgroups()[0]
        self.repository = Repository(repository, warn_on_empty_repository,
                                     group)
        self.defstrat = strat
    #   Setting this to 0 pretty much guarantees that the first
    #   time we run get or put, a purge will run. TODO: is that a
    #   good thing?
        self.last_purge = 0
        if scheduler is None:
            if (opportunistic_cleanup in (True, None)
                and ALLOW_OPPORTUNISTIC_CLEANUP):
                self.opportunistic = True
            else:
                self.opportunistic = False
        else:
            if opportunistic_cleanup in (False, None):
                self.opportunistic = False
            else:
                self.opportunistic = True
            if purge_every <= 0:
                raise ParameterError(
                    "Purge interval must be supplied for scheduled cleanup")
            scheduler.schedule_every(self.purge, purge_every)
    def group(self):
        """
        The group ID with which all cache entries are written.
        """
        return self.repository.group
    def default_strategy(self):
        """
        Returns: strat
            strat: function
                The default expiration strategy
        """
        if self.defstrat is None:
            raise rexcept.ParameterError("No caching strategy supplied")
        else:
            return self.defstrat

    def reserve(self, op, kwargs):
        """
        Parameters:
        op
            Operation object being cached
        kwargs
            Arguments to invocation of operation, normalized into a dictionary

        Returns:
            A CacheTransaction for the item       
        """
        trace_method_call(self)
        callinfo = CallInfo.from_op(op, kwargs)
        bucket = Bucket(self.repository, callinfo.digest())
        transaction = CachePutTransaction(
            self, callinfo, op.mime_type, bucket)
        return transaction

    def put(self, transaction, expires):
        """
        Parameters:
        transaction
            CachePutTransaction used to create cacheable item
        """
        trace_method_call(self)
        self.opportunistic_cleanup()    
        get_log().debug("Putting %s in cache", transaction)
        meta = transaction.commit(expires)
        return meta

    def get(self, op, kwargs):
        trace_method_call(self)
        self.opportunistic_cleanup()    
        callinfo = CallInfo.from_op(op, kwargs)
        bucket = Bucket(self.repository, callinfo.digest())
        winner = None
        for meta in bucket.find_entries(callinfo):
            get_log().debug("Candidate: %s", str(meta))
            if meta.expired():
                get_log().debug("Entry is expired. Moving on....")
                continue
            if (winner is None or
                meta.created_timestamp > winner.created_timestamp):
                winner = meta
        get_log().debug("Winner is %s", winner)
        return winner

    def opportunistic_cleanup(self):
        if self.opportunistic:
            l = get_log("cleanup")
            diff = int(time.time() - self.last_purge) / 60 # Only want minutes
        #   Clean up about 20% of the time, modified by 20% for every minute
        #   since the last cleanup.
            sample = random.random()
            to_beat = .20 * diff
            if sample < to_beat:
                l.debug("%f < %f: initiating cleanup", sample, to_beat)
                self.purge()
                self.last_purge = time.time()
            else:
                l.debug("%f >= %f: no cleanup", sample, to_beat)

    def purge(self):
        "Remove cache entries identified as expired."
        trace_method_call(self)
        for meta in self.repository.ancient_metas():
            bucket = Bucket(self.repository, meta.callinfo.digest())
            bucket.purge_entry(meta)
