"""quixote.publish

Logic for publishing modules and objects on the Web.
"""

__revision__ = "$Id: publish.py,v 1.12 2000/06/01 19:23:17 akuchlin Exp $"

import sys, traceback, os, string, cStringIO
import time, types

from quixote.util import fcgi
from quixote.zope import HTTPRequest, HTTPResponse

import config

# Dictionary used to store the time when a module was imported
time_imported = {}

# Internal globals used to hold the root package and its name and path.
_root_package = None
_root_package_name = None
_root_package_path = None

def get_root_package_info ():
    """get_root_package_info () -> (path, name)

    Returns the full filesystem path, and the name, of the root package.
    """
    
    global _root_package_name, _root_package_path

    if _root_package_name is None:
        root_package = import_root_package()
    
        path, dummy = os.path.split( root_package.__file__)
        _root_package_path = root_package.__path__
        _root_package_name = root_package.__name__

    return _root_package_path, _root_package_name
    
def import_root_package ():
    "Import the root package and return it"
    global _root_package

    if _root_package is None:
        # Import the configured root package
        top = __import__( config.ROOT_PACKAGE )
        path = string.split( config.ROOT_PACKAGE, '.')
        for component in path[1:]:
            top = getattr(top, component)

        _root_package = top
        
        # Set the import time for all the imported modules
        # to now.
        now = time.time()
        modules = _list_all_package_modules()
        for name in modules:
            time_imported[ name ] = now
                
    return _root_package

def _list_all_package_modules ():
    """Return a list of all the names in sys.modules which form part of
    the configured Web application package.
    """

    modules = []
    path, package_name = get_root_package_info()
    length = len(package_name) + 1

    for name, module in sys.modules.items():
        if (name == package_name or
            name[:length] == package_name + '.'):
            modules.append( name )

    return modules

def reload_package ():
    "Force a reload of the entire Web application package"
    modules = _list_all_package_modules()
    
    for name in modules:
        del sys.modules[name]
        if time_imported.has_key(name):
            del time_imported[name]
    
    # Force the root package to be re-imported
    global _root_package
    _root_package = None
    import_root_package()


def _traverse (path):
    """traverse(path:list of strings) -> (needs_reload,object)

    Perform traversal based on the provided path, starting at the root
    object.  It returns the arrived-at object, and a Boolean denoting
    whether a reload of the Web application package is necessary.

    It's expected that the final object is something callable like a
    function or a method; intermediate objects along the way will
    usually be packages or modules.

    To prevent crackers from writing URLs that traverse private
    objects, every package, module, or object along the way must have
    an __exports__ attribute containing a list of publicly visible
    names.  Not having an __exports__ attribute is an error, though
    having __exports__ be an empty list is OK (though useless).  If a
    component of the path isn't in __exports__, that also produces an
    error.

    If Quixote has been configured with the AUTOMATIC_RELOAD flag set
    to true, modules are checked along the way, and the returned value
    of needs_reload will be true if any of the modules are out of date
    with respect to their source file.
    
    """

    # Traverse starting at the root
    object = import_root_package()
    needs_reload = 0

    # Loop over the components of the path, checking at every step
    # 1) whether __exports__ is present, 2) whether the path component
    # is listed in __exports__, and 3) if the current object is module,
    # does it need to be reloaded?
    
    for component in path:
        if not hasattr(object, '__exports__'):
            # XXX what exception to raise here?
            raise ValueError, "Object %s has no __exports__" % repr(object)

        if component not in object.__exports__:
            raise ValueError, ("Attribute %s of object %s "
                               "not in __exports__: %s" %
                               (repr(component), object, object.__exports__) )

        # If the object is a module, and the source file is more recent,
        # reload the module and note the time of reloading.
        if (config.AUTOMATIC_RELOAD and needs_reload == 0 and
            type(object) == types.ModuleType):
            filename = object.__file__
            if os.path.isdir( filename ):
                filename = os.path.join( filename, '__init__.py')
            elif filename[ -4: ] == '.pyc': filename = filename[:-1]
            
##            sys.stderr.write('Check reload %s: import %s, src %s %s\n' %
##                             (object.__name__,
##                              time_imported.get( object.__name__, 0),
##                              os.path.getmtime( filename ),
##                              filename) )

            if (time_imported.get( object.__name__, 0) <
                os.path.getmtime( filename) ):
                needs_reload = 1
            
        # Get next object and go around the loop again
        if type(object) is not types.ModuleType:
            object = getattr(object, component)
        else:
            # If it's a module and the attribute isn't there,
            # try importing the next component
            if hasattr(object, component):
                object = getattr(object, component)

            else:
                mod_name = object.__name__ + '.' + component
                __import__( mod_name )
                object = sys.modules[ mod_name ]
                time_imported[ mod_name ] = time.time()

    return needs_reload, object


def traverse_url ():
    """traverse_url() -> object

    Reads the PATH_INFO environment variable and traverses the path
    found there.  If the package needs reloading, it will be reloaded
    and the traversal repeated to get the updated version of the object.

    If the object ends up being a module, it's checked for a function
    named 'index', and that module will be returned instead.
    """

    # Get path and split it apart; /foo/bar/baz -> ['foo', 'bar', 'baz']
    path = os.environ.get('PATH_INFO', '')
    path = string.split(path, '/')
    path = filter(None, path)              # Drop empty strings
    
    needs_reload, object = _traverse(path)
    if needs_reload:
        sys.stderr.write('Reloading Web application package\n')
        reload_package()
        needs_reload, object = _traverse(path)

        # XXX The result of needs_reload from the second call is
        # simply ignored; it might still be true, particularly if the
        # source file is living on an NFS-mounted partition whose
        # clock is a few seconds or minutes out of sync.  Oh well.
        pass

    if type(object) == types.ModuleType:
        # It's a module, which isn't callable.  Check if the module
        # has a function named 'index', and use that function instead
        if (hasattr(object, 'index') and 'index' in object.__exports__):
            object = object.index
            
    return object

def send_traceback (traceback_msg, request, response):
    """send_traceback(traceback_msg : string,
                      request : HTTPRequest,
                      response : HTTPResponse)

    If Quixote has been configured to do so, send an e-mail notifying
    someone of a traceback. 
    """
    
    if config.ERROR_EMAIL is None: return
    
    pipe = os.popen('/usr/lib/sendmail -f "Quixote" %s' % config.ERROR_EMAIL, 'w')
    pipe.write('Subject: Traceback\n')
    pipe.write('\n')

    pipe.write('Traceback\n=========\n')
    traceback.print_exc( file = pipe)

    if request is None:
        pipe.write('\nRequest is None\n')
    else:
        pipe.write('\nRequest\n=======\n')
        pipe.write( request.dump() )

    if response is None:
        pipe.write('\nResponse is None\n')
    else:
        pipe.write('\nResponse\n=======\n')
        pipe.write( str(response) )

    pipe.close()
    

def call ():
    """call() -> None

    The master function that does all the work for a single request.
    call() uses traverse_url() to get a callable object, creates the
    'request' and 'response' objects, and calls the object.  If it
    raises an exception, the traceback is logged and e-mailed.
    
    """

    orig_stdout = sys.stdout
    if config.LOG_FILENAME is not None:
        sys.stdout = open(config.LOG_FILENAME, 'a')
    else:
        sys.stdout = cStringIO.StringIO()
        
    request = response = None
    
    try:
        # Traverse package to a (hopefully-) callable object
        callable = traverse_url()

        # Build Request, Response objects
        response = HTTPResponse.HTTPResponse()
        request = HTTPRequest.HTTPRequest( sys.stdin,
                                           os.environ,
                                           response, clean=0 )
        request.processInputs()
        
        # Call object
        output = callable(request, response)
        response.setBody ( output )
        
        # Output results from Response object
        orig_stdout.write( str( response ) )
    
    except:
        traceback_file = cStringIO.StringIO()
        traceback.print_exc( file = traceback_file)

        if config.DISPLAY_EXCEPTIONS:
            orig_stdout.write('Content-type: text/plain\n\n')
            orig_stdout.write( traceback_file.getvalue() )
            
        sys.stderr.write( traceback_file.getvalue() )
        send_traceback( traceback_file.getvalue(), request, response )
            
    sys.stdout.close()
    sys.stdout = orig_stdout

def call_fcgi ():
    """call_fcgi() -> None

    Entry point from CGI scripts; it will repeatedly do the call()
    function until there are no more FCGI requests.  This function can
    also be called in regular CGI scripts, in which case call() is
    called exactly once.
    """
    
    while fcgi.isFCGI():
        f = fcgi.FCGI()
        os.environ = f.env
        call()
        f.Finish()
        
