"""quixote.publish

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

__revision__ = "$Id: publish.py,v 1.55 2001/01/11 15:34:31 nascheme Exp $"

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

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


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 get_component (container, component, path, request):
    # First security check: if the container doesn't even have an
    # __exports__ list, fail now: all Quixote-traversable namespaces
    # (modules, packages, instances) must have an export list!
    if not hasattr(container, '__exports__'):
        raise errors.TraversalError(request.get_path(), 
            "%s has no __exports__ list" % repr(container))

    # Second security check: call __access__ function if it's present.
    if hasattr(container, '__access__'):
        # will raise AccessError if access failed
        container.__access__(request)
        
    # Third security check: make sure the current name component is
    # in the export list.
    if component in container.__exports__:
        is_module = type(container) is types.ModuleType
        has_component = hasattr(container, component)
        if is_module and not has_component:
            # get next module
            mod_name = container.__name__ + '.' + component
            # If we get an ImportError here we don't catch it.  It means
            # that either someone exported something that doesn't exist or
            # an exception was raised from deeping in the code.  A
            # traceback should be generated in either case.
            object = get_module(mod_name)

        elif has_component:
            object = getattr(container, component)

        else: 
            # not a module, doesn't have 'component'
            raise errors.TraversalError(request.get_path(),
                "object %s has no attribute '%s'" % (`container`, component))
    else:
        # Component is not in exports list.  Check if namespace provides a
        # '__getname__()' function, 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 hasattr(container, "__getname__"):
            object = container.__getname__(request, component)
        else:
            raise errors.TraversalError(request.get_path(),
                ("%s not in __exports__ list for %s" %
                 (repr(component), container)))
    
    return object

def traverse_url (path, request):
    """traverse_url(path : string, request : HTTPRequest)
       -> (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.
    """
    from quixote.config import config
    
    # replace repeated slashes with a single slash
    path = re.sub("//*", "/", path)

    # split path apart; /foo/bar/baz  -> ['foo', 'bar', 'baz']
    #                   /foo/bar/     -> ['foo', 'bar', '']
    path_components = string.split(path[1:], '/')

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

    # Loop over the components of the path
    index = 0
    for component in path_components:
        index = index + 1
        if component == "":
            # "/q/foo/" == "/q/foo/index"
            component = "index"
        object = get_component(object, component, path, request)

    if not callable(object):
        # We went through all the components of the path and ended up at
        # something which isn't callable, like a module or an instance
        # without a __call__ method.
        if path[-1] != '/':
            if not request.form and config.fix_trailing_slash:
                # This is for the convenience of users who type in paths.
                # Repair the path and redirect.  This should no happen for
                # URLs within the site.
                path = "%s/%s/" % (config.url_prefix,
                                   string.join(path_components, '/'))
                def redirect_slash(request, path=path):
                    return request.redirect(path, permanent=1)
                return '', '', redirect_slash
            else:
                # Automatic redirects disabled or there is form data.  If
                # there is form data then the programmer is using the
                # wrong path.  A redirect won't work if the form data came
                # from a POST anyhow.
                raise errors.TraversalError(path, "missing trailing slash?")
        else:
            raise error.TraversalError(path, "uncallable object %s" % `object`)

    # Determine the script name and path info to be returned.
    script = string.join([config.url_prefix] + path_components[:index], '/')
    path_info = ""

    return script, path_info, object


def mail_error (msg):
    """mail_error(msg : string) -> None

    If Quixote has been configured to do so, send an e-mail notifying
    someone of a traceback. 
    """
    from quixote.config import config
    if config.error_email is None: return

    cmd = "/usr/lib/sendmail -f Quixote '%s'" % config.error_email
    pipe = os.popen(cmd, 'w')
    pipe.write('Subject: Quixote Traceback\n')
    pipe.write('From: Quixote on %s <%s>\n' % (socket.gethostname(),
                                               config.error_email) )
    pipe.write('\n')
    pipe.write(msg)
    pipe.close()


def error_handler (request, exception, error_msg):
    """error_handler(request : HTTPRequest,
                     exception : Exception,
                     error_msg: string) -> string

    Return an error page to the user.  This function is called if the
    configuration variable ERROR_HANDLER is not set.  The error_msg
    contains a traceback and a dump if the request and response objects.  
    """
    from quixote.config import config
    request.response.setHeader("Content-Type", "text/plain")
    request.response.setStatus(500)
    if config.display_exceptions:
        return error_msg
    else:
        return ("An error occurred while handling your request.  Check "
                "the Quixote\nerror log for details.")

def report_error (request, original_response, exc_info):
    from quixote.config import config
    exc_type, exc_value, tb = exc_info
    error_file = cStringIO.StringIO()
    # format the traceback
    error_file.write('Traceback\n'
                     '=========\n')
    traceback.print_exception(exc_type, exc_value, tb, file=error_file)
    # include request and response dumps
    error_file.write('\nRequest\n'
                       '=======\n')
    error_file.write(request.dump())
    error_file.write('\nOriginal Response\n'
                       '=================\n')
    error_file.write(str(original_response))
    error_msg = error_file.getvalue()
    if config.error_log is None:
        sys.stderr.write(error_msg)
    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(error_msg)
            errlog.close()
    mail_error(error_msg)
    # return an error message to the browser
    func = error_handler
    if config.error_handler:
        components = string.split(config.error_handler, ".")
        module_name = string.join(components[:-1], ".")
        func_name = components[-1]
        module = get_module(module_name)
        func = getattr(module, func_name, func)
    return func(request, exc_value, error_msg)
    

exit_now = 0
    
def call (stdin, stdout, stderr):
    """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 object, 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

    sess_coll = session.get_session_collection()
    
    # Build request, response objects
    request = HTTPRequest.HTTPRequest(stdin, os.environ)
    request.processInputs()

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

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

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

        # Call object
        try:
            output = object(request)
        except SystemExit:
            output = "Server restarted"
            exit_now = 1

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

    except errors.PublishError, exc:
        # abort the publishing loop and return a result right way
        sess_coll.abort() # XXX Provide a way of controlling this!  Maybe
                          # the PublishError exception should have an
                          # attribute to control this.
        request.response = HTTPResponse.HTTPResponse()
        output = exc.format(request)
        
    except:
        # some other exception, generate error messages to the logs, etc.
        sess_coll.abort()
        # build new response to be safe
        old_response = request.response
        request.response = HTTPResponse.HTTPResponse()
        output = report_error(request, old_response, sys.exc_info())

    else:
        # The callable ran OK, commit any changes to the session
        sess_coll.commit()

    # Output results from Response object
    request.response.setBody(output)
    stdout.write(str(request.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.
    """
    from quixote.config import config
    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
