# Python module to cache the results of eventually constant functions
# Copyright (C) 2006 Josef Spillner <josef@kuarepoti-dju.net>
# Published under GNU GPL conditions
#
# Quick usage:
#  import cache
#  fib(5) -> ...
#  cache.cache(fib, globals())
#  fib(5) -> ...
#  cache.stats()
#
# Documentation:
#  All methods are documented using PyDoc strings.
#  This text therefore tries to introduce the concept of eventually constant
#  functions and how a generic cache is helpful for optimization and
#  durability of results.
#
# Constant functions are (mathematical) functions which always return the
# same result when given the same input, without any side-effects. An example
# is the Fibonacci function or the integer squares.
# Eventually constant means that an error value can be returned, as long as
# the final constant return value is not yet available. An example is a file
# download method.
#
# For caching, simply pass the method to cache.cache(), with the second
# argument always being globals(). For eventually constant functions, use
# cacheretry() and add the distinct error value as third parameter.
#
# Beside this basic functionality, some more module-level methods are
# available. With cache.stats(), some statistics can be written to stdout.
# During the work, output can be generated when cache.verbose(1) was called.
# Temporarily switching off the cache can happen via cache.disable(), followed
# by a cache.enable() call to reenable the caching.
# Calling cache.clear() really erases all cached contents.
#
# In order to support a permanent cache, the cache.backingfile() method takes
# a single file name. This file is then used to store all cache values which
# otherwise only exist in main memory.
#
# ---------------------------------------------------------------------------

# Global cache object of class __cacheclass
# This object gets instantiated automatically, it should not be used outside
# of this module.
__cacheobject = None

# The main cache class
# Exists as a singleton object and holds caches for as many python methods
# as is desired. Can be configured by calling module-level (not object-level)
# methods which will follow the class definition.
class __cacheclass:

	# Initialization of the object
	# Reinstantiation will completely reset the cache state.
	def __init__(self):
		self.__inv = 0
		self.__cache = {}
		self.__misses = 0
		self.__hits = 0
		self.__cacheenabled = 1
		self.__verbose = 0
		self.__cachemethod = None
		self.__cacheredirects = {}
		self.__backingfile = None
		self.__retries = {}

	# Necessary self representation method
	def __repr__(self):
		return "cacheclass {" + str(self.__inv) + " invocations}"

	# Necessary string conversion method
	def __str__(self):
		return self.__repr__()

	# Dummy call method for corner cases
	# This should actually not be necessary but the on-demand object
	# initialization code requires it.
	def __call__(self):
		return 1

	# Magic cache redirect handler
	# This method always gets injected into the application namespace
	# in lieu of the original (cached) method. By applying clever
	# resolving algorithms, the original method is then called. Hence,
	# the __cacheobject is actually a non-visible proxy.
	def __getattr__(self, x):
		if x == "__nonzero__":
			return self

		if x == "__redirectcall":
			return self.__redirectcall

		if x.find("__redirect__") == 0:
			if self.__verbose:
				print "[cacheobject] getaddr", x
			self.__cachemethod = x.replace("__redirect__", "")
			return self.__redirectcall
		else:
			return self.__dict__["_cacheclass" + x]
			# FIXME: this is a grave bug (one instead of two leading underscores)
			# FIXME: but at least it works with one cached method...

	# Magic cache redirect call
	# This method actually calls the cached method and places its result
	# into the cache and possibly into the backing file as well.
	def __redirectcall(self, name, *args):
		self.__inv += 1

		self.__cachemethod = name

		if self.__cache[self.__cachemethod].has_key(str(args)):
			self.__hits += 1
			return self.__cache[self.__cachemethod][str(args)]
		else:
			self.__misses += 1

		if self.__verbose:
			print "[cacheobject] invoke redirect", args
			print "[cacheobject] on method", self.__cachemethod

		argline = ""
		i = 0
		for arg in args:
			if i > 0:
				argline += ", "
			argline += "args[" + str(i) + "]"
			i += 1

		func = self.__cacheredirects[self.__cachemethod]
		res = eval("func.__call__(" + argline + ")")

		if self.__cacheenabled:
			ignore = 0
			if self.__retries.has_key(self.__cachemethod):
				retryval = self.__retries[self.__cachemethod]
				if retryval == res:
					ignore = 1
			if not ignore:
				self.__cache[self.__cachemethod][str(args)] = res
				if self.__backingfile:
					f = open(self.__backingfile, "a")
					f.write(str((name, args, res)) + "\n")
					f.close()

		return res

	# Turn on or off verbosity (off by default)
	def setverbose(self, x):
		self.__verbose = x

	# Turn on or off the caching itself (on by default)
	def setenabled(self, x):
		self.__cacheenabled = x

	# Force a cleared cache
	# This does not affect persistant backing files.
	def clear(self):
		for c in self.__cache:
			self.__cache[c] = {}
		self.__misses = 0
		self.__hits = 0
		self.__inv = 0

	# Dynamic addition of method to __cacheobject
	# This adds a global (module-level) method and an associated method
	# for the __cacheobject which corresponds to the cached method.
	def setup(self, name):
		exec("def __redirect__" + name + "(args): return __cacheobject.__redirectcall('" + name + "', args)")
		exec("self.__redirect__" + name + " = __redirect__" + name)

	# Setup the usage of a backing file
	# If the file already exists, e.g. from previous runs, then the
	# already cached values are read in at first.
	def backingfile(self, filename):
		self.__backingfile = filename

		f = open(self.__backingfile, "r")
		lines = f.readlines()
		for line in lines:
			line = line.rstrip()
			line = line[1:-1]
			(name, args, res) = line.split(", ")
			name = name[1:-1]
			if self.__cacheenabled:
				if not self.__cache.has_key(name):
					self.__cache[name] = {}
				self.__cache[name][args] = res
		f.close()

# Cache a constant method
# This method takes a python method and the globals() namespace
# said method lives in, in order to be able to substitute it with
# the magic proxy call.
def cache(x, xenv):
	global __cacheobject

	if not __cacheobject:
		__cacheobject = __cacheclass()

	if __cacheobject.__verbose:
		print "[cache] ok, caching", x
	savefunc = xenv[x.func_name]

	__cacheobject.setup(x.func_name)
	redirectfunc = eval("__cacheobject.__redirect__" + x.func_name)

	# FIXME: globals() etc. do not work from within module
	# FIXME: With xenv it at least works but still not automatically
	xenv[x.func_name] = redirectfunc
	__cacheobject.__cacheredirects[x.func_name] = savefunc
	__cacheobject.__cache[x.func_name] = {}
	if __cacheobject.__verbose:
		print "[cache] caching initiated!"

# Cache an eventually constant method
# The only difference to cache() is that a retry value can be given, that is,
# a return value (like an error indicator) which is not considered for caching
def cacheretry(x, xenv, retry):
	print "[cache] caching with retry argument..."
	cache(x, xenv)
	__cacheobject.__retries[x.func_name] = retry

# Print some statistics
# These statistics are global, that is, aggregated from all calls to all
# cached methods.
def stats():
	print "[cache]", __cacheobject.__inv, "invocations"
	print "[cache]", __cacheobject.__hits, "hits", __cacheobject.__misses, "misses"

# Enable the caching
# This (re)enables the cache functionality in case it was disabled.
def enable():
	global __cacheobject

	try:
		__cacheobject.setenabled(1)
	except:
		pass

# Disable the caching
# This is useful to suspend caching temporarily without losing state
# information about methods or statistics.
def disable():
	global __cacheobject

	try:
		__cacheobject.setenabled(0)
	except:
		pass

# Turn on verbosity
# Wraps the corresponding __cacheclass method.
def verbose(x):
	global __cacheobject

	try:
		__cacheobject.setverbose(x)
	except:
		pass

# Resets the object
# Wraps the corresponding __cacheclass method.
def clear():
	global __cacheobject

	try:
		__cacheobject.clear()
	except:
		pass

# Sets up a backing file
# Wraps the corresponding __cacheclass method.
def backingfile(f):
	global __cacheobject

	try:
		__cacheobject.backingfile(f)
	except:
		pass


