view openwebchat.py @ 67:4f449ed51dd3

Made cmd-line args more flexible, robust, and readable.
author Atul Varma <varmaa@toolness.com>
date Thu, 30 Apr 2009 11:29:56 -0700
parents 3d5683e4b0e2
children 878871076cfa
line wrap: on
line source

import os
import sys
import math
import re
import httplib
import cStringIO
import mimetools
import weakref
import cgi

from cosocket import *
import channels

try:
    import json
except ImportError:
    import simplejson as json

class Conversations(object):
    def __init__(self):
        self._convs = weakref.WeakValueDictionary()

    def __len__(self):
        return len(self._convs)

    def get(self, name):
        if name not in self._convs:
            filename = '%s.conversation' % name
            if not os.path.exists(filename):
                open(filename, 'w').close()

            conv = Conversation(open(filename, 'r+w'))
            self._convs[name] = conv
        else:
            conv = self._convs[name]
        return conv

class Conversation(list):
    def __init__(self, fileobj):
        list.__init__(self)
        self.__file = fileobj
        items = []
        for line in self.__file.readlines():
            items.append(json.loads(line))
        self[:] = items

    def append(self, item):
        list.append(self, item)
        self.__file.write('%s\n' % json.dumps(item))
        self.__file.flush()

def _parse_qs(querystring):
    querydict = {}
    cgi_querydict = cgi.parse_qs(querystring)
    for key, value in cgi_querydict.items():
        querydict[key] = cgi_querydict[key][0]
    return querydict

class OpenWebChatServer(object):
    QUERYSTRING_TEMPLATE = re.compile('([^\?]*)\?(.*)')
    REDIRECT_TEMPLATE = re.compile('\/([A-Za-z0-9_]+)')
    URL_TEMPLATE = re.compile('\/([A-Za-z0-9_]+)/(.*)')

    BOUNDARY = "'''"

    BLOCK_SIZE = 8192

    MIME_TYPES = {'html' : 'text/html',
                  'js' : 'text/javascript',
                  'css' : 'text/css'}

    def __init__(self, addr, conversations, is_keep_alive = False):
        self._is_keep_alive = is_keep_alive
        self._num_connections = 0
        self._convs = conversations
        self._server = CoroutineSocketServer(addr,
                                             self._server_coroutine)

    def run(self):
        self._server.run()

    def _until_http_response_sent(self, msg = '', mimetype = 'text/plain',
                                  length = None, code = 200,
                                  additional_headers = None):
        headers = {'Content-Type': mimetype}
        if self._is_keep_alive:
            headers.update({'Keep-Alive': 'timeout=99, max=99',
                            'Connection': 'Keep-Alive'})
        if additional_headers:
            headers.update(additional_headers)
        if not mimetype.startswith('multipart'):
            if length is None:
                length = len(msg)
            headers['Content-Length'] = str(length)

        header_lines = ['HTTP/1.1 %d %s' % (code,
                                            httplib.responses[code])]
        header_lines.extend(['%s: %s' % (key, value)
                             for key, value in headers.items()])
        header_lines.extend(['', msg])
        content = '\r\n'.join(header_lines)
        yield until_sent(content)

    def _until_file_sent(self, filename):
        mimetype = self.MIME_TYPES[filename.split('.')[-1]]

        length = os.stat(filename).st_size
        num_blocks = length / self.BLOCK_SIZE
        if length % self.BLOCK_SIZE:
            num_blocks += 1
        infile = open(filename, 'r')

        yield self._until_http_response_sent(mimetype = mimetype,
                                             length = length)

        for i in range(num_blocks):
            # TODO: This could be bad since we're reading the file
            # synchronously.
            block = infile.read(self.BLOCK_SIZE)
            yield until_sent(block)

    def _server_coroutine(self, addr):
        self._num_connections += 1
        try:
            if self._is_keep_alive:
                while 1:
                    yield self._until_one_request_processed(addr)
            else:
                yield self._until_one_request_processed(addr)
        finally:
            self._num_connections -= 1

    def __multipart_boundary(self, boundary, mimetype = 'application/json'):
        # Here we actually declare the content type and start the
        # transfer of the document itself; this is needed to
        # trigger an error on the browser-side, because a closed
        # connection during any other phase is unlikely to
        # cause any event to trigger on the client webpage.
        return '\r\n'.join(('--%s' % boundary,
                            'Content-Type: %s' % mimetype,
                            '',
                            ''))

    def _until_multipart_header_sent(self, boundary):
        yield self._until_http_response_sent(
            self.__multipart_boundary(boundary),
            mimetype = ('multipart/x-mixed-replace; '
                        'boundary="%s"' % boundary))

    def _until_multipart_part_sent(self, boundary, msg):
        yield until_sent('\r\n'.join(
                (msg,
                 '',
                 self.__multipart_boundary(boundary))
                ))

    def _parse_id(self, string):
        i = 0
        if string:
            try:
                i = int(string)
                if i < 0:
                    i = 0
            except ValueError:
                pass
        return i

    def _until_conv_request_processed(self, addr, headers, method,
                                      conv_name, page):
        match = self.QUERYSTRING_TEMPLATE.match(page)
        querydict = {}
        if match:
            querydict.update(_parse_qs(match.group(2)))
            page = match.group(1)
        if page == 'listen':
            i = self._parse_id(querydict.get('start'))
            conv = self._convs.get(conv_name)
            while i >= len(conv):
                yield channels.until_message_received(conv_name)
            yield self._until_http_response_sent(
                json.dumps({'messages': conv[i:]}),
                mimetype = 'application/json'
                )
        elif page == 'listen/multipart':
            i = self._parse_id(querydict.get('start'))
            yield self._until_multipart_header_sent(self.BOUNDARY)
            conv = self._convs.get(conv_name)
            while 1:
                while i < len(conv):
                    msg = json.dumps(conv[i])
                    i += 1
                    yield self._until_multipart_part_sent(self.BOUNDARY, msg)
                yield channels.until_message_received(conv_name)
        elif page == 'send':
            length = int(headers.getheader('Content-Length', 0))
            msg = yield until_received(bytes = length)
            conv = self._convs.get(conv_name)
            conv.append(json.loads(msg))
            yield channels.until_message_sent(conv_name, None)
            yield self._until_http_response_sent('sent.')
        elif page in ['', 'jquery.js', 'openwebchat.js',
                      'json2.js', 'openwebchat.css']:
            if page == '':
                filename = 'openwebchat.html'
            else:
                filename = page

            yield self._until_file_sent(filename)
        else:
            yield self._until_http_response_sent('not found',
                                                 code = 404)

    def _until_one_request_processed(self, addr):
        request = yield until_received(terminator = '\r\n\r\n')
        request = request.splitlines()
        request_line = request[0]
        stringfile = cStringIO.StringIO('\n'.join(request[1:]))
        headers = mimetools.Message(stringfile)
        req_parts = request_line.split()
        method = req_parts[0]
        match = self.URL_TEMPLATE.match(req_parts[1])
        if not match:
            match = self.REDIRECT_TEMPLATE.match(req_parts[1])
            if match:
                newpath = req_parts[1] + '/'
                yield self._until_http_response_sent(
                    newpath,
                    code = 301,
                    additional_headers = {'Location': newpath}
                    )
            else:
                yield self._until_http_response_sent('not found',
                                                     code = 404)
        else:
            conv_name = match.group(1)
            page = match.group(2)
            if conv_name == 'status':
                # TODO: Return 404 if page is non-empty.
                lines = ('open connections  : %d' % self._num_connections,
                         'open conversations: %d'% len(self._convs))
                yield self._until_http_response_sent('\r\n'.join(lines))
            else:
                yield self._until_conv_request_processed(addr, headers,
                                                         method, conv_name,
                                                         page)

class Args(object):
    def __str__(self):
        items = []
        for key in self.__dict__:
            items.append('%s=%s' % (key, self.__dict__[key]))
        return ', '.join(items)

def get_args(args = sys.argv[1:], **kwargs):
    new_args = Args()
    regexp = re.compile(r'([A-Za-z0-9_]+)=(.*)')
    for key in kwargs:
        setattr(new_args, key, kwargs[key])

    for arg in args:
        match = regexp.match(arg)
        if match:
            key = match.group(1)
            if key not in kwargs:
                raise ValueError('invalid argument: %s' % key)
            value = match.group(2)
            constructor = type(kwargs[key])
            if constructor == bool:
                constructor = make_boolish
            setattr(new_args, key, constructor(value))
    return new_args

def make_boolish(val):
    if type(val) is not bool:
        if val.lower() in ['true', '1', 'yes']:
            val = True
        elif val.lower() in ['false', '0', 'no']:
            val = False
        else:
            raise ValueError('not a boolean: %s' % val)
    return val

if __name__ == '__main__':
    args = get_args(ip = '127.0.0.1',
                    port = 8071,
                    is_keep_alive = True)

    server = OpenWebChatServer(addr = (args.ip, args.port),
                               conversations = Conversations(),
                               is_keep_alive = args.is_keep_alive)
    print "Starting server with configuration: %s" % args
    server.run()