"""quixote.publish

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

__revision__ = "$Id: publish.py,v 1.42 2000/10/03 21:48:38 nascheme Exp $"

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

from quixote import init, session, errors
from quixote.util import fcgi
from quixote.zope import HTTPRequest, HTTPResponse
from quixote import config


def get_module(name):
    """Get a module object by name."""
    __import__(name)
    module = sys.modules[name]
    return module
    
def _get_module_filename(module):
    filename = module.__file__
    if os.path.isdir( filename ):
        filename = os.path.join( filename, '__init__.py')
    elif filename[ -4: ] == '.pyc': filename = filename[:-1]

    return filename

def _traverse (original_path, path, request, response):
    """_traverse(original_path : string,
                 path : [string],
                 request, response : various objects) ->
          (script_name: string, path_info : string, object : any)

    Perform traversal based on the provided path, starting at the root
    object.  It returns the script name and path info values for
    the arrived-at object, along with the object itself.

    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.
    """

    # Traverse starting at the root
    object = get_module(config.ROOT_PACKAGE)

    # 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__

    index = 0
    while index < len(path):
        component = path[index]

        # First security check: if the current doesn't even have an
        # __exports__ list, fail now: all Quixote-traversable namespaces
        # (modules, packages, instances) must have an export list!
        if not hasattr(object, '__exports__'):
            raise errors.TraversalError(
                "%s has no __exports__ list" % repr(object),
                original_path)

        # Second security check: make sure the current name component is
        # in the export list.  If not, there's a backdoor around the
        # usual traversal algorithm: namespaces may provide a
        # '__getname__()' function/method, which translates an arbitrary
        # string into an object that we continue traversing.  This is
        # very handy; it lets you put user-space objects into your
        # URL-space, eliminating the need for digging ID strings out of
        # a query, or checking PATHINFO after Quixote's done with it.
        # But it is a compromise to security: it opens up the traversal
        # algorithm to arbitrary names not listed in __exports__!
        if component not in object.__exports__:
            getname = getattr(object, '__getname__', None)
            if getname is not None:
                try:
                    object = getname(component)
                    if callable(object):
                        break
                except ValueError, msg:
                    raise errors.TraversalError(msg, original_path)
                index = index + 1
                continue
            else:
                raise errors.TraversalError(
                    ("%s not in __exports__ list for %s" %
                     (repr(component), object)),
                    original_path)

        # Call __access__ function or method, if it's present
        if hasattr(object, '__access__'):
            access_func = getattr(object, '__access__')
            # will raise AccessError if access failed
            access_func(request, response)
            
        is_module = type(object) is types.ModuleType
        has_component = hasattr(object, component)
        if is_module and not has_component:
            # get next module
            mod_name = object.__name__ + '.' + component
            try:
                object = get_module(mod_name)
            except ImportError, msg:
                raise errors.TraversalError(
                    (("module %s has no attribute '%s', "
                      "and failed to import %s: %s") %
                     (object.__name__, component, mod_name, msg)),
                    original_path)

        elif has_component:             # and 'object' is not a module
            object = getattr(object, component)

        else:                           # not a module, doesn't  'component'
            raise errors.TraversalError(
                "object %s has no attribute '%s'" % (`object`, component),
                original_path)

        # Bail out of the 'while' loop early once we reach something
        # callable (eg. a method or function).
        if callable(object):
            break

        index = index + 1

    # Determine the script name and path info to be returned.
    # If path_info is not the empty string, it must begin with a
    # leading slash, to guarantee that
    # "http://$SERVER_NAME:$SERVER_PORT$SCRIPT_NAME$PATH_INFO" will
    # always be an accessible URL that points to the current script.
    # This invariant comes from the cgi_path.html file in the Apache docs.
    script_name = config.URL_PREFIX + '/' + string.join(path[:index+1], '/')
    path_info =   string.join(path[index+1:], '/')
    if path_info != "": path_info = '/' + path_info 
    
    return script_name, path_info, object


def traverse_url (request, response):
    """traverse_url() -> (script_name, path_info, object)

    Reads the PATH_INFO environment variable and traverses the path
    found there.

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

    # Get path and split it apart; /foo/bar/baz -> ['foo', 'bar', 'baz']
    original_path = os.environ.get('PATH_INFO', '')
    path = string.split(original_path, '/')
    path = filter(None, path)              # Drop empty strings
    
    (script_name, path_info, object) = \
        _traverse(original_path, path, request, response)

    # ExtensionClass-proof test for "this is a class instance"
    is_instance = (hasattr(object, '__class__') and
                   not hasattr(object, '__bases__'))
    is_module = type(object) == types.ModuleType

    if is_module:
        # It's a module, which isn't callable.  First, make sure URL has
        # trailing slash
        if original_path[-1] != '/':
            # repair path and redirect
            path = "%s/%s/" % (config.URL_PREFIX, string.join(path, '/'))
            def redirect_slash(request, response, path=path):
                return response.redirect(path)
            return '', '', redirect_slash
        
        # Check if the module has a function named 'index', and use that
        # function instead
        if (hasattr(object, 'index') and
            callable(object.index) and
            'index' in object.__exports__):
            object = object.index
        else:
            raise errors.TraversalError(
                "module %s has no function index (or it's not in __exports__)"
                % object.__name__,
                original_path)

    # Instances get a similar treatment to modules, except they may be
    # callable already (in which case we leave them be, and just call
    # them).
    elif is_instance:
        if callable(object):
            pass                        # object is just fine as-is
        elif (hasattr(object, 'index') and
              callable(object.index) and
              'index' in object.__exports__):
            object = object.index
        else:
            raise errors.TraversalError(
                ("object %s is not callable and has no 'index' method "
                 " (or 'index' not in __exports__)") % `object`,
                original_path)

    return script_name, path_info, 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()

exit_now = 0
    
def call (stdin, stdout, stderr, *args):
    """call(stdin : file, stdout : file, stderr : file, ...)

    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.

    Any extra arguments will be passed to the callable.
    """
    global exit_now

    output = request = response = None
    sess_coll = session.get_session_collection()
    
    # Build request, response objects
    response = HTTPResponse.HTTPResponse( server_name = os.environ['SERVER_NAME'] )
    request = HTTPRequest.HTTPRequest( stdin, os.environ, response )
    request.processInputs()

    try:
        # Get the session object and stick it onto the request
        request.session = session.get_session (request, response)

        # Traverse package to a (hopefully-) callable object
        (os.environ['SCRIPT_NAME'],
         os.environ['PATH_INFO'],
         object) = traverse_url(request, response)

        if not callable(object):
            raise RuntimeError, "object not callable: %s" % repr(object)

        # Call object
        try:
            args = (request, response) + args
            output = apply(object, args)
        except SystemExit:
            output = "Server restarted"
            exit_now = 1

        if output is None:
            raise RuntimeError, 'Callable %s returned None' % repr(object)

    except errors.IgnoredException:
        # The callable ran OK, so return its output to the client
        sess_coll.commit()               # Commit any changes to the session

        location = response.headers['location']
        output = '<a href="%s">Follow this link</a>' % location
        response.setBody ( output )
        
        # Output results from Response object
        stdout.write( str( response ) )
        
    except errors.AccessError, msg:
        sess_coll.abort()
        if os.environ.get('PATH_INFO') == config.ACCESS_VIOLATION_PAGE:
            # recursion, eak!
            raise RuntimeError, "access violation page raised AccessError"
        os.environ['PATH_INFO'] = config.ACCESS_VIOLATION_PAGE
        call(stdin, stdout, stderr, msg) 

    except:
        traceback_file = cStringIO.StringIO()
        traceback.print_exc( file = traceback_file)

        if config.DISPLAY_EXCEPTIONS:
            stdout.write('Content-type: text/plain\n\n')
            stdout.write( traceback_file.getvalue() )

        tb = traceback_file.getvalue()
        if config.ERROR_LOG is None:
            sys.stderr.write( tb )
        else:
            try:
                errlog = open(config.ERROR_LOG, 'a')
            except IOError, exc:
                sys.stderr.write("error opening error log %s: %s\n" 
                                 % (`config.ERROR_LOG`, exc.strerror))
            else:
                errlog.write(tb)
                errlog.close()
            
        send_traceback( tb, request, response )

        # Abort any changes to the session
        sess_coll.abort()

    else:
        # The callable ran OK, so return its output to the client
        sess_coll.commit()               # Commit any changes to the session
        response.setBody ( output )

        # Output results from Response object
        stdout.write( str( response ) )


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() and not exit_now:
        f = fcgi.FCGI()
        os.environ = f.env
        call(f.inp, f.out, f.err)
        f.Finish()
        if config.RUN_ONCE: break
