#
#  $Id: AppContext.py,v 1.5 1999/12/20 10:15:09 rob Exp $
#
#  Copyright 1999 Rob Tillotson <robt@debian.org>
#  All Rights Reserved
#
#  Permission to use, copy, modify, and distribute this software and
#  its documentation for any purpose and without fee or royalty is
#  hereby granted, provided that the above copyright notice appear in
#  all copies and that both the copyright notice and this permission
#  notice appear in supporting documentation or portions thereof,
#  including modifications, that you you make.
#
#  THE AUTHOR ROB TILLOTSON DISCLAIMS ALL WARRANTIES WITH REGARD TO
#  THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
#  SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
#  RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
#  CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
#  CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE!
#
"""
"""

__version__ = '$Id: AppContext.py,v 1.5 1999/12/20 10:15:09 rob Exp $'

__copyright__ = 'Copyright 1999 Rob Tillotson <robt@debian.org>'


import sys, os, string, operator
from types import ClassType

from Application import *
import misc
import Registry

try:
    import cPickle
    pickle = cPickle
except:
    import pickle

# error levels.
ERR_WARNING = 'warning'
ERR_NORMAL  = 'error'


# Application context
# note that this IS NOT a plug-in.

# The AppContext object is a more-or-less abstract superclass for the
# object that will eventually be used to instantiate and run an application;
# it calls back to various stub methods to find, load, and preconfigure
# the application object.  (The AppContext object can be used directly if
# the application object is located by some other means, such as by being
# instantiated by another application's get_plugin method.)
#  TODO: make an AppContext subclass expressly designed to run one app
#        inside another
#
# Actual behavior is provided by some mixin classes which can be combined
# to make a complete AppContext object.  Only a couple of these are implemented
# right now: PathAppLoader and CLIProcessor.  The point of this is to provide
# a separation of jobs: the 'processor' handles the user interface end of things,
# the 'loader' finds and instantiates the application, and the context class
# handles the rest.  The processor class is probably most amenable to replacement,
# perhaps by something with a GUI.  The loader might be harder to replace,
# given how dependent Sulfur is on the regular Python module system, but it
# could conceivably be replaced with something that (for example) prods a CORBA
# server into returning an Application object.
#


# mixins:
#
#  - loading and instantiating the app [this may call back to plugin manager]
#  - plugin manager (including registration) [may call back to state manager]
#  - state manager (manages persistent app state and options)
#  - startup manager (finds out what app to run and gets initial options from the user)
#  - user interface manager (handles log() and error() )


class AppContext:
    app_type = 'Sulfur'
    
    def __init__(self, app=None):
	# The original design for this class used compile-time inheritance to
	# paste the various parts of the context together.  However, this makes
	# it difficult to assemble a variety of different contexts at runtime,
	# or to change the context behind the application's back.  Thus, this
	# code now uses a form of acquisition -- each of the following objects
	# is one part of the interface, and stub functions will be wrapped
	# around each one to provide the visible interface.
	#
	# Each one of these will have a 'start' and 'stop' method as well as
	# whatever other interfaces are necessary.
	# 
	self.loader = None
	self.startup_manager = None
	self.state_manager = None
	self.plugin_manager = None
	self.ui = None
	#
	self.app = app
	self.preloaded_plugins = []
	
    # change_* are only for use while the app is running.
    def change_loader(self, obj):
	if self.loader is not None: self.loader.stop()
	self.loader = obj
	self.loader.start(self)

    def change_startup(self, obj):
	if self.startup_manager is not None: self.startup_manager.stop()
	self.startup_manager = obj
	self.startup_manager.start(self)

    def change_state(self, obj):
	if self.state_manager is not None: self.state_manager.stop()
	self.state_manager = obj
	self.state_manager.start(self)

    def change_plugin_manager(self, obj):
	if self.plugin_manager is not None: self.plugin_manager.stop()
	self.plugin_manager = obj
	self.plugin_manager.start(self)

    def change_ui(self, obj):
	if self.ui is not None: self.ui.stop()
	self.ui = obj
	self.ui.start(self)

    # external API to applications.
    # we will use a form of acquisition here, searching all of the internal
    # pieces of the context... ui and plugin_manager are first, since they
    # are the ones that provide external interfaces at present.
    def __getattr__(self, k):
	if hasattr(self.ui, k): return getattr(self.ui, k)
	if hasattr(self.plugin_manager, k): return getattr(self.plugin_manager, k)
	if hasattr(self.state_manager, k): return getattr(self.state_manager, k)
	if hasattr(self.startup_manager, k): return getattr(self.startup_manager, k)
	if hasattr(self.loader, k): return getattr(self.loader, k)
	raise AttributeError, k
    
    def fatal_error(self, s):
	raise RuntimeError, s
    
    def unload(self):
	# unlink app context here
	self.app = None
	self.preloaded_plugins = []

    def __call__(self, *a, **kw):
	# order of initialization
	#  - initialize UI
	#  - load state
	#  - create and load registry
	#  - initialize plugin manager
	#  - find and instantiate application class [uses Loader and Startup]
	#  - call app.prerun()
	#  - call get_initial_args()
	#  - preload plugins if needed
	#  - call app.run()
	#  - call app.postrun()
	#  - save state
	#
	self.ui.start(self)
	self.state_manager.start(self)
	self.plugin_manager.start(self)
	self.loader.start(self)
	self.startup_manager.start(self)
	
	if self.app is None:
	    self.app = self.loader.get_app_object()
	    if self.app is None:
		self.fatal_error("could not find application")

	# even though this might have been done already by the plugin
	# manager, we do it again just in case.
	self.app.configure(self.registry)
	self.app.context = self
	self.app.registry = self.registry
	#
	self.app.prerun()
	# get_initial_args can change the app option values
	argv = self.startup_manager.get_initial_args()
	apply(self.app.run, (argv,)+a, kw)
	self.app.postrun()
	# save state
	self.startup_manager.stop()
	self.loader.stop()
	self.plugin_manager.stop()
	self.state_manager.stop()
	self.ui.stop()

class ContextComponent:
    def __init__(self):
	self.context = None
    def start(self, context):
	self.context = context
    def stop(self):
	self.context = None
	
# LOADERS
#
class Loader(ContextComponent):
    def get_app_object(self): return None
    
class PluginLoader(Loader):
    def __init__(self, collection='Application'):
	self.collection = collection
	
    def get_app_path(self):
	path = [ self.context.app_type ]
	ev = '%sPLUGINS' % string.upper(self.context.app_type)
	if os.environ.has_key(ev):
	    path = string.split(os.environ[ev], ':') + path
	return path

    def get_app_object(self):
	name = self.context.startup_manager.get_app_name()
	path = self.get_app_path()
	if type(name) == type(''):
	    return self.context.plugin_manager.get_plugin(self.collection, name)
	else:
	    for n in name:
		m = self.context.plugin_manager.get_plugin(self.collection, name)
		if m is not None: return m
	    return None
	
    def __load(self, name, path):
	m = None
	for pe in path:
	    if path: pe = pe + '.' + self.collection
	    else: pe = self.collection
	    try:
		m = misc.import_module(pe+'.'+name, globals())
		break
	    except:
		pass
	if m is None: return None
	for o in m.__dict__.values():
	    if type(o) == ClassType and hasattr(o, 'is_plugin'):
		return o
	return None
	

# STARTUP MANAGERS
#
class Startup(ContextComponent):
    def fatal_error(self, s): raise RuntimeError, s
    def get_app_name(self): return sys.argv[0]
    def get_initial_args(self): return []
    def help(self): pass
    
class CLIStartup(Startup):
    def fatal_error(self, s):
	print "Error initializing application context:", s
	sys.exit(2)

    def get_app_name(self):
	a = os.path.basename(sys.argv[0])
	n = None
	# if argv[0] does not end in ".py{c,o}", assume that this is being
	# invoked from a symlink; the name of this symlink can be
	# 'bar-foo' (where bar is the app_type) or just 'foo'.
	# otherwise, assume that the first argument is the application to run.
	# obviously, doing something useful with this on systems that don't
	# have Unix symlink and argv semantics may require overriding of
	# this code.
	if a[-3:] != '.py' and a[-4:] not in ['.pyc', '.pyo'] and \
	   a[-4:] != '-run': # FIXME this is a total hack
	    if a[:len(self.context.app_type)+1] == string.lower(self.context.app_type)+'-':
		n = a[len(self.context.app_type)+1:]
	    else:
		n = a
	else:
	    if len(sys.argv) > 1:
		n = sys.argv[1]
		sys.argv = sys.argv[1:]
	if n is None:
	    self.fatal_error("Cannot determine what application to run.")
	return n
	
    def get_initial_args(self):
	opts = {}
	for k, v in self.context.app.option_refs.items(): opts[k] = (self.context.app, v)
	for p in self.context.preloaded_plugins:
	    for k, v in p.option_refs.items(): opts[k] = (p, v)

	try:
	    argv = parse_cmd_options(sys.argv[1:], opts)
	except OptionParsingError, err:
	    print "error:", err
	    print
	    self.help()
	    sys.exit(1)

	# fixme
	if self.context.app.auto_cmd_help and self.context.app.has_option('help') \
	   and self.context.app.get_option('help'):
	    self.help()
	    sys.exit(0)

	return argv

    def help(self):
	hl = []
	l = []
	d = {}
	d.update(self.context.app.option_refs)
	#for o in extra_options: d[o.name] = o
	hl.append( (self.context.app, process_help(d)) )
	for p in self.context.preloaded_plugins: hl.append( (p, process_help(p.option_refs)) )

	# who says Python can't be written obscurely?
	maxlen = max(reduce(operator.add,
			    map(lambda x: map(lambda y: len(y[0]), x),
				map(lambda x: x[1], hl)), []))

	if maxlen > 40: maxlen = 40

	for p, h in hl:
	    if p is not self.context.app: l.append('%s: ' % (p.type_name and p.type_name or p.type))
	    l.append('%s %s by %s\n' % (p.name, p.version, p.author))
	    l.append(p.description)
	    l.append('\n')
	    if h:
		l.append('\n')
		l.append(help_string(h, maxlen))
		l.append('\n')

	if self.context.app.extra_help: l.append(self.app.extra_help)
	
	print string.join(l, '')

#
# STATE MANAGERS
#
# self.state is a dictionary-like object with application-defined contents.
# (other parts of the context may stuff things here too).
#
# self.state_load() is called early in the application running process,
# and it should load the state from persistent storage.
#
# self.state_save() is called after the application terminates, and it
# should save the state to where it can be loaded later.
#
# The state manager is also responsible for creating, loading, and saving the
# registry; eventually these will be folded together, maybe.
class State(ContextComponent):
    def start(self, context):
	ContextComponent.start(self, context)
	context.state = {}
	context.registry = Registry.Registry()
    
class PickleState(State):
    """A simple State Manager that stores the state in a pickle."""
    def __init__(self, path="~/.%s_state"):
	self.path = path
	
    def start(self, context):
	State.start(self, context)
	if string.find(self.path, '%s') >= 0:
	    self.state_file_name = os.path.expanduser(self.path % string.lower(self.context.app_type))
	else:
	    self.state_file_name = os.path.expanduser(self.path)
	try:
	    context.state = pickle.load(open(self.state_file_name))
	except:
	    context.state = {}

    def stop(self):
	try:
	    pickle.dump(self.context.state, open(self.state_file_name, 'w'))
	except:
	    pass
	State.stop(self)
	

# PLUGIN MANAGERS
#
# The plugin manager also handles products, which are collections of
# plugin collections.
#
class PluginManager(ContextComponent):
    def start(self, context):
	ContextComponent.start(self, context)
	self.plugins = {}
    def get_plugin(self, collection, name, *a, **kw): return None
    def list_plugins(self, collection): return []
    def list_plugin_info(self, collection): return []
    
class OriginalPluginManager(PluginManager):
    def start(self, context):
	PluginManager.start(self, context)
	path = [ context.app_type ]
	ev = '%sPLUGINS' % string.upper(context.app_type)
	if os.environ.has_key(ev):
	    path = string.split(os.environ[ev], ':') + path
	self.plugin_module_path = path
	self.plugins = {}
	if context.state.has_key('products'):
	    prd = {}
	    for pkg in self.context.state['products'].values():
		if pkg and pkg not in self.plugin_module_path:
		    self.plugin_module_path.append(pkg)
	
    def get_plugin(self, collection, name, *a, **kw):
	pname = collection + '.' + name
	
	if self.plugins.has_key(pname): return self.plugins[pname]

	for pe in self.plugin_module_path:
	    if pe: pe = pe + '.' + collection
	    else: pe = collection

	    try:
		m = misc.import_module(pe+'.'+name, globals())
		for o in m.__dict__.values():
		    if type(o) == ClassType and hasattr(o, 'is_plugin'):
			p = apply(o, a, kw)
			p.set_context(self.context.app)
			p.configure(self.context.registry)
			self.plugins[pname] = p
			return p
	    except:
		pass
	raise ImportError, 'Plugin: %s.%s' % (collection, name)

    def list_plugins(self, collection):
	"""List plugins in a particular collection.
	"""
	return misc.list_plugins(self.plugin_module_path, collection)

    def list_plugin_info(self, collection):
	"""List information about plugins in a particular collection.
	"""
	return misc.list_plugin_info(self.plugin_module_path, collection)

    # products
    # the API is not really specific to the concept of package-as-product,
    # but there seems no way to completely divorce the code from it.  This
    # probably makes sense, because if you radically change the way the
    # plugin manager works, you probably also have to radically change your
    # idea of what a product is...
    def product_install(self, package):
	if not self.context.state.has_key('products'):
	    self.context.state['products'] = {}

	try:
	    m = misc.import_module(package)
	except:
	    m = None

	if not m:
	    raise ImportError, "Could not import package '%s'" % package

	if not hasattr(m, '%s_PRODUCT' % string.upper(self.context.app_type)):
	    raise RuntimeError, "Package '%s' does not appear to contain a Sulfur product." % package

	name = getattr(m, '%s_PRODUCT' % string.upper(self.context.app_type))['name']
	
	if package not in self.plugin_module_path:
	    self.plugin_module_path.append(package)

	self.context.state['products'][name] = package

	return name
    
    def product_list(self):
	if not self.context.state.has_key('products'):
	    self.context.state['products'] = {}

	l = []
	for name, pkg in self.context.state['products'].items():
	    try:
		m = misc.import_module(pkg)
	    except:
		continue
	    if not hasattr(m, '%s_PRODUCT' % string.upper(self.context.app_type)):
		continue
	    p = getattr(m, '%s_PRODUCT' % string.upper(self.context.app_type))
	    q = {'package': pkg,
		 'name': p.get('name'),
		 'author': p.get('author'),
		 'version': p.get('version'),
		 'description': p.get('description')
		 }
	    l.append(q)
	l.sort(lambda x,y: cmp(x.get('name'),y.get('name')))
	return l

    def product_remove(self, name):
	if not self.context.state.has_key('products'):
	    self.context.state['products'] = {}

	if self.context.state['products'].has_key(name):
	    p = self.context.state['products'][name]
	    if p and p in self.plugin_module_path:
		self.plugin_module_path.remove(p)
	    del self.context.state['products'][name]

    def product_installed(self, name):
	if self.context.state.has_key('products'):
	    return self.context.state['products'].has_key(name)

    def product_info(self, name):
	for p in self.product_list():
	    if p['name'] == name: return p
	return {}

StandardPluginManager = OriginalPluginManager


# USER INTERFACES
class UI(ContextComponent):
    def log(self, s, name=None): pass
    def error(self, s, level='warning', name=None): pass

# yes, this looks redundant, but it is meant to allow for translatability.
_error_levels = {
    ERR_WARNING: 'warning',
    ERR_NORMAL : 'error',
    }

class BasicUI(UI):
    def start(self, context):
	UI.start(self, context)
	self.logfile = sys.stdout
	
    def log(self, s, name=None):
	if name is None: self.logfile.write('%s\n' % s)
	else: self.logfile.write('%s: %s\n' % (name, s))

    def error(self, s, level=ERR_NORMAL, name=None):
	t = _error_levels.get(level, 'error')
	if name is None: self.logfile.write('%s: %s\n' % (s, level))
	else: self.logfile.write('%s: %s: %s\n' % (name, s, level))

class CLIAppContext(AppContext):
    def __init__(self, *a, **kw):
	apply(AppContext.__init__, (self,)+a, kw)
	self.loader = PluginLoader()
	self.startup_manager = CLIStartup()
	self.state_manager = PickleState()
	self.plugin_manager = OriginalPluginManager()
	self.ui = BasicUI()
	

