diff options
-rw-r--r-- | wsgitools/__init__.py | 0 | ||||
-rw-r--r-- | wsgitools/applications.py | 27 | ||||
-rw-r--r-- | wsgitools/filters.py | 218 | ||||
-rw-r--r-- | wsgitools/middlewares.py | 267 | ||||
-rw-r--r-- | wsgitools/scgi.py | 188 |
5 files changed, 700 insertions, 0 deletions
diff --git a/wsgitools/__init__.py b/wsgitools/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/wsgitools/__init__.py diff --git a/wsgitools/applications.py b/wsgitools/applications.py new file mode 100644 index 0000000..1e2b435 --- /dev/null +++ b/wsgitools/applications.py @@ -0,0 +1,27 @@ +class StaticContent: + """This wsgi application provides static content on whatever request it + receives.""" + def __init__(self, status, headers, content): + """status is the HTTP status returned to the browser (ex: "200 OK") + headers is a list of (header, value) pairs being delivered as HTTP + headers + content contains the data to be delivered to the client. It is either a + string or some kind of iterable yielding strings. + """ + self.status = status + self.headers = headers + length = -1 + if isinstance(content, basestring): + self.content = [content] + length = len(content) + else: + self.content = content + if isinstance(self.content, list): + length = sum(map(len, self.content)) + if length >= 0: + if not [v for h, v in headers if h.lower() == "content-length"]: + headers.append(("Content-length", str(length))) + def __call__(self, environ, start_response): + """wsgi interface""" + start_response(self.status, self.headers) + return self.content diff --git a/wsgitools/filters.py b/wsgitools/filters.py new file mode 100644 index 0000000..1d6154f --- /dev/null +++ b/wsgitools/filters.py @@ -0,0 +1,218 @@ +__all__ = [] + +import sys +import time + +__all__.append("CloseableIterator") +class CloseableIterator: + """Concatenating iterator with close attribute.""" + def __init__(self, close_function, *iterators): + """If close_function is not None, it will be the close attribute of + the created iterator object. Further parameters specify iterators + that are to be concatenated.""" + if close_function is not None: + self.close = close_function + self.iterators = map(iter, iterators) + def __iter__(self): + """iterator interface""" + return self + def next(self): + """iterator interface""" + if not self.iterators: + raise StopIteration + try: + return self.iterators[0].next() + except StopIteration: + self.iterators.pop(0) + return self.next() + +__all__.append("CloseableList") +class CloseableList(list): + """A list with a close attribute.""" + def __init__(self, close_function, *args): + """If close_function is not None, it will be the close attribute of + the created list object. Other parameters are passed to the list + constructor.""" + if close_function is not None: + self.close = close_function + list.__init__(self, *args) + def __iter__(self): + return CloseableIterator(getattr(self, "close", None), + list.__iter__(self)) + +__all__.append("BaseWSGIFilter") +class BaseWSGIFilter: + """Generic WSGI filter class to be used with WSGIFilterMiddleware. + + For each request a filter object gets created. + The environment is then passed through filter_environ. + Possible exceptions are filtered by filter_exc_info. + After that for each (header, value) tuple filter_header is used. + The resulting list is filtered through filter_headers. + Any data is filtered through filter_data. + In order to possibly append data the append_data method is invoked. + When the request has finished handle_close is invoked. + + All methods do not modify the passed data by default. Passing the + BaseWSGIFilter class to a WSGIFilterMiddleware will result in not modifying + the request at all. + """ + def __init__(self): + """This constructor does nothing and can safely be overwritten. It is + only listed here to document that it must be callable without additional + parameters.""" + pass + def filter_environ(self, environ): + """Receives a dict with the environment passed to the wsgi application + and a dict must be returned. The default is to return the same dict.""" + return environ + def filter_exc_info(self, exc_info): + """Receives either None or a tuple passed as third argument to + start_response from the wrapped wsgi application. Either None or such a + tuple must be returned.""" + return exc_info + def filter_status(self, status): + """Receives a status string passed as first argument to start_response + from the wrapped wsgi application. A valid HTTP status string must be + returned.""" + return status + def filter_header(self, headername, headervalue): + """This function is invoked for each (headername, headervalue) tuple in + the second argument to the start_response from the wrapped wsgi + application. Such a value or None for discarding the header must be + returned.""" + return (headername, headervalue) + def filter_headers(self, headers): + """A list of headers passed as the second argument to the start_response + from the wrapped wsgi application is passed to this function and such a + list must also be returned.""" + return headers + def filter_data(self, data): + """For each string that is either written by the write callable or + returned from the wrapped wsgi application this method is invoked. It + must return a string.""" + return data + def append_data(self): + """This function can be used to append data to the response. A list of + strings or some kind of iterable yielding strings has to be returned. + The default is to return an empty list. + """ + return [] + def handle_close(self): + """This method is invoked after the request has finished.""" + pass + +__all__.append("WSGIFilterMiddleware") +class WSGIFilterMiddleware: + """This wsgi middleware can be used with specialized BaseWSGIFilters to + modify wsgi requests and/or reponses.""" + def __init__(self, app, filterclass): + """app is a wsgi application. + filterclass is a subclass of BaseWSGIFilter or some class that + implements the interface.""" + self.app = app + self.filterclass = filterclass + def __call__(self, environ, start_response): + """wsgi interface""" + filter = self.filterclass() + environ = filter.filter_environ(environ) + + def modified_start_response(status, headers, exc_info=None): + exc_info = filter.filter_exc_info(exc_info) + status = filter.filter_status(status) + headers = (filter.filter_header(h, v) for h, v in headers) + headers = [h for h in headers if h is not None] + headers = filter.filter_headers(headers) + write = start_response(status, headers, exc_info) + def modified_write(data): + write(filter.filter_data(data)) + return modified_write + + ret = self.app(environ, modified_start_response) + + def modified_close(): + filter.handle_close() + getattr(ret, "close", lambda:0)() + + if isinstance(ret, list): + return CloseableList(modified_close, + [filter.filter_data(data) for data in ret] + + list(filter.append_data())) + ret = iter(ret) + return CloseableIterator(modified_close, + (filter.filter_data(data) for data in ret), + filter.append_data()) + +__all__.append("RequestLogWSGIFilter") +class RequestLogWSGIFilter(BaseWSGIFilter): + """This filter logs all requests in the apache log file format.""" + @classmethod + def creator(cls, log): + return lambda:cls(log) + def __init__(self, log=sys.stdout): + self.log = log + self.time = time.strftime("%d/%b/%Y:%T %z") + self.length = 0 + def filter_environ(self, environ): + """BaseWSGIFilter interface""" + self.remote = environ.get("REMOTE_ADDR", "?") + self.reqmethod = environ["REQUEST_METHOD"] + self.path = environ["SCRIPT_NAME"] + environ["PATH_INFO"] + self.proto = environ.get("SERVER_PROTOCOL", None) + self.referrer = environ.get("HTTP_REFERER", "-") + self.useragent = environ.get("HTTP_USER_AGENT", "-") + return environ + def filter_status(self, status): + """BaseWSGIFilter interface""" + self.status = status.split()[0] + return status + def filter_data(self, data): + """BaseWSGIFilter interface""" + self.length += len(data) + return data + def handle_close(self): + """BaseWSGIFilter interface""" + line = '%s - - [%s] "%s' % (self.remote, self.time, self.reqmethod) + line = '%s %s' % (line, self.path) + if self.proto is not None: + line = "%s %s" % (line, self.proto) + line = '%s" %s %d' % (line, self.status, self.length) + if self.referrer is not None: + line = '%s "%s"' % (line, self.referrer) + else: + line += " -" + if self.useragent is not None: + line = '%s "%s"' % (line, self.useragent) + else: + line += " -" + print >> self.log, line + +__all__.append("TimerWSGIFilter") +class TimerWSGIFilter(BaseWSGIFilter): + @classmethod + def creator(cls, pattern): + return lambda:cls(pattern) + def __init__(self, pattern="?GenTime"): + self.pattern = pattern + self.start = time.time() + def filter_data(self, data): + """BaseWSGIFilter interface""" + if data == self.pattern: + return "%8.3g" % (time.time() - self.start) + return data + +__all__.append("EncodeWSGIFilter") +class EncodeWSGIFilter(BaseWSGIFilter): + """Encodes all body data (no headers) with given charset.""" + @classmethod + def creator(cls, charset): + return lambda:cls(charset) + def __init__(self, charset="utf-8"): + self.charset = charset + def filter_data(self, data): + """BaseWSGIFilter interface""" + return data.encode(self.charset) + def filter_header(self, header, value): + if header.lower() != "content-type": + return (header, value) + return (header, "%s; charset=%s" % (value, self.charset)) diff --git a/wsgitools/middlewares.py b/wsgitools/middlewares.py new file mode 100644 index 0000000..9eab2a1 --- /dev/null +++ b/wsgitools/middlewares.py @@ -0,0 +1,267 @@ +__all__ = [] + +import time +from filters import CloseableList, CloseableIterator +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +__all__.append("SubdirMiddleware") +class SubdirMiddleware: + def __init__(self, default, mapping={}): + self.default = default + self.mapping = mapping + def __call__(self, environ, start_response): + app = None + script = environ["PATH_INFO"] + path_info = "" + while '/' in script: + if script in self.mapping: + app = self.mapping[script] + break + script, tail = script.rsplit('/', 1) + path_info = "/%s%s" % (tail, path_info) + if app is None: + app = self.mapping.get(script, None) + if app is None: + app = self.default + environ["SCRIPT_NAME"] += script + environ["PATH_INFO"] = path_info + return app(environ, start_response) + +__all__.append("NoWriteCallableMiddleware") +class NoWriteCallableMiddleware: + """This middleware wraps a wsgi application that needs the return value of + start_response function to a wsgi application that doesn't need one by + writing the data to a StringIO and then making it be the first result + element.""" + def __init__(self, app): + """Wraps wsgi application app.""" + self.app = app + def __call__(self, environ, start_response): + """wsgi interface""" + todo = [] + def modified_start_response(status, headers, exc_info=None): + if exc_info is not None: + todo.append(None) + return start_response(status, headers) + else: + sio = StringIO.StringIO() + todo.append((status, headers, sio)) + return sio.write + + ret = self.app(environ, modified_start_response) + + if todo and todo[0] is None: + return ret + + if isinstance(ret, list): + status, headers, data = todo[0] + data = data.getvalue() + if data: + ret.insert(0, data) + start_response(status, headers) + return ret + + ret = iter(ret) + stopped = False + try: + first = ret.next() + except StopIteration: + stopped = True + + status, headers, data = todo[0] + data = data.getvalue() + start_response(status, headers) + + if stopped: + return CloseableList(getattr(ret, "close", None), (data,)) + + return CloseableIterator(getattr(ret, "close", None), + (data, first), ret) + +__all__.append("ContentLengthMiddleware") +class ContentLengthMiddleware: + """Guesses the content length header if possible. + Note: The application used must not use the write callable returned by + start_response.""" + def __init__(self, app, maxstore=0): + """Wraps wsgi application app. It can also store the first result bytes + to possibly return a list of strings which will make guessing the size + of iterators possible. At most maxstore bytes will be accumulated. + Please note that a value larger than 0 will violate the wsgi standard. + The magical value () will make it always gather all data. + """ + self.app = app + self.maxstore = maxstore + def __call__(self, environ, start_response): + """wsgi interface""" + todo = [] + def modified_start_response(status, headers, exc_info=None): + if (exc_info is not None or + [v for h, v in headers if h.lower() == "content-length"]): + todo[:] = (None,) + return start_response(status, headers, exc_info) + else: + todo[:] = ((status, headers),) + def raise_not_imp(*args): + raise NotImplementedError + return raise_not_imp + + ret = self.app(environ, modified_start_response) + + if todo and todo[0] is None: # nothing to do + #print "content-length: nothing" + return ret + + if isinstance(ret, list): + #print "content-length: simple" + status, headers = todo[0] + length = sum(map(len, ret)) + headers.append(("Content-length", str(length))) + start_response(status, headers) + return ret + + ret = iter(ret) + stopped = False + data = CloseableList(getattr(ret, "close", None)) + length = 0 + try: + data.append(ret.next()) # fills todo + length += len(data[-1]) + except StopIteration: + stopped = True + + status, headers = todo[0] + + while (not stopped) and length < self.maxstore: + try: + data.append(ret.next()) + length += len(data[-1]) + except StopIteration: + stopped = True + + if stopped: + #print "content-length: gathered" + headers.append(("Content-length", str(length))) + start_response(status, headers) + return data + + #print "content-length: passthrough" + start_response(status, headers) + + return CloseableIterator(getattr(ret, "close", None), data, ret) + +def storable(environ): + if environ["REQUEST_METHOD"] != "GET": + return False + return True + +def cacheable(environ): + if environ.get("HTTP_CACHE_CONTROL", "") == "max-age=0": + return False + return True + +__all__.append("CachingMiddleware") +class CachingMiddleware: + """Caches reponses to requests based on SCRIPT_NAME, PATH_INFO and + QUERY_STRING.""" + def __init__(self, app, maxage=60, storable=storable, cacheable=cacheable): + """app is a wsgi application to be cached. + maxage is the number of seconds a reponse may be cached. + storable is a predicated that determines whether the response may be + cached at all based on the environ dict. + cacheable is a predicate that determines whether this request + invalidates the cache.""" + self.app = app + self.maxage = maxage + self.storable = storable + self.cacheable = cacheable + self.cache = {} + def __call__(self, environ, start_response): + """wsgi interface""" + if not self.storable(environ): + return self.app(environ, start_response) + path = environ.get("SCRIPT_NAME", "/") + path += environ.get("PATH_INFO", '') + path += "?" + environ.get("QUERY_STRING", "") + if self.cacheable(environ) and path in self.cache: + if self.cache[path][0] + self.maxage >= time.time(): + start_response(self.cache[path][1], self.cache[path][2]) + return self.cache[path][3] + else: + del self.cache[path] + cache_object = [time.time(), "", [], []] + def modified_start_respesponse(status, headers, exc_info): + if exc_info is not None: + return self.app(status, headers, exc_info) + cache_object[1] = status + cache_object[2] = headers + write = start_response(status, headers) + def modified_write(data): + cache_object[3].append(data) + write(data) + return modified_write + ret = self.app(environ, modified_start_respesponse) + if isinstance(ret, list): + cache_object[3].extend(ret) + self.cache[path] = cache_object + return ret + def pass_through(): + for data in ret: + cache_object[3].append(data) + yield data + self.cache[path] = cache_object + return CloseableIterator(getattr(ret, "close", None), pass_through()) + +__all__.append("DictAuthChecker") +class DictAuthChecker: + def __init__(self, users): + self.users = users + def __call__(self, username, password): + return username in self.users and self.users[username] == password + +__all__.append("BasicAuthMiddleware") +class BasicAuthMiddleware: + """Middleware implementing HTTP Basic Auth.""" + def __init__(self, app, check_function, realm='www'): + """app is a WSGI application. + check_function is a function taking two arguments username and password + returning a bool indicating whether the request may is + allowed.""" + self.app = app + self.check_function = check_function + self.realm = realm + + def __call__(self, environ, start_response): + """wsgi interface""" + auth = environ.get("HTTP_AUTHORIZATION") + if not auth or ' ' not in auth: + return self.authorization_required(environ, start_response) + auth_type, enc_auth_info = auth.split(None, 1) + try: + auth_info = enc_auth_info.decode("base64") + except: # It throws some non-standard exception. + return self.authorization_required(environ, start_response) + if auth_type.lower() != "basic" or ':' not in auth_info: + return self.authorization_required(environ, start_response) + username, password = auth_info.split(':', 1) + if self.check_function(username, password): + environ["REMOTE_USER"] = username + return self.app(environ, start_response) + return self.authorization_required(environ, start_response) + + def authorization_required(self, environ, start_response): + """wsgi application for indicating authorization is required.""" + status = "401 Authorization required" + headers = [('Content-type', 'text/html'), + ('WWW-Authenticate', 'Basic realm="%s"' % self.realm)] + if environ["REQUEST_METHOD"] == "HEAD": + start_response(status, headers) + return [] + html = "<html><head><title>Authorization required</title></head>" + \ + "<body><h1>Authorization required</h1></body></html>\n" + headers.append(('Content-length', len(html))) + start_response(status, headers) + return [html] diff --git a/wsgitools/scgi.py b/wsgitools/scgi.py new file mode 100644 index 0000000..e5b7dfc --- /dev/null +++ b/wsgitools/scgi.py @@ -0,0 +1,188 @@ +__all__ = [] + +import asyncore +import socket +import sys +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +class SCGIConnection(asyncore.dispatcher): + """SCGI connection class used by WSGISCGIServer.""" + # maximum request size + MAX_REQUEST_SIZE = 65536 + # maximum post size + MAX_POST_SIZE = 8 << 20 + # read and write size + BLOCK_SIZE = 4096 + # connection states + NEW = 0*4 | 1 # connection established, waiting for request + HEADER = 1*4 | 1 # the request length was received, waiting for the rest + BODY = 2*4 | 1 # the request header was received, waiting for the body + REQ = 3*4 | 2 # request received, sending response + def __init__(self, server, connection, addr): + self.server = server # WSGISCGIServer instance + self.addr = addr # scgi client address + self.state = SCGIConnection.NEW # internal state + self.environ = {} # environment passed to wsgi app + self.reqlen = -1 # request length used in two different meanings + self.inbuff = "" # input buffer + self.outbuff = "" # output buffer + self.wsgihandler = None # wsgi application iterator + self.outheaders = () # headers to be sent + # () -> unset, (..,..) -> set, True -> sent + self.body = StringIO.StringIO() # request body + asyncore.dispatcher.__init__(self, connection) + + def _wsgi_headers(self): + return {"wsgi.version": (1, 0), + "wsgi.input": self.body, + "wsgi.errors": self.server.error, + "wsgi.url_scheme": "http", # TODO: this is wrong + "wsgi.multithread": False, + "wsgi.multiprocess": False, + "wsgi.run_once": False} + + def _try_send_headers(self): + if self.outheaders != True: + assert not self.outbuff + status, headers = self.outheaders + headdata = "".join(map("%s: %s\r\n".__mod__, headers)) + self.outbuff = "Status: %s\r\n%s\r\n" % (status, headdata) + self.outheaders = True + + def _wsgi_write(self, data): + assert self.state >= SCGIConnection.REQ + self._try_send_headers() + self.outbuff += data + + def readable(self): + """asyncore interface""" + return self.state & 1 == 1 + + def writable(self): + """asyncore interface""" + return self.state & 2 == 2 + + def handle_read(self): + """asyncore interface""" + data = self.recv(self.BLOCK_SIZE) + self.inbuff += data + if self.state == SCGIConnection.NEW: + if ':' in self.inbuff: + l, self.inbuff = self.inbuff.split(':', 1) + if not l.isdigit(): + self.close() + return # invalid request format + l = long(l) + if l > self.MAX_REQUEST_SIZE: + self.close() + return # request too long + self.reqlen = l + self.state = SCGIConnection.HEADER + elif len(self.inbuff) > self.MAX_REQUEST_SIZE: + self.close() + return # request too long + + if self.state == SCGIConnection.HEADER: + buff = self.inbuff[:self.reqlen] + remainder = self.inbuff[self.reqlen:] + + while buff.count('\0') >= 2: + k, v, buff = buff.split('\0', 2) + self.environ[k] = v + self.reqlen -= len(k) + len(v) + 2 + + self.inbuff = buff + remainder + + if self.reqlen == 0: + if self.inbuff.startswith(','): + self.inbuff = self.inbuff[1:] + self.reqlen = long(self.environ["CONTENT_LENGTH"]) + if self.reqlen > self.MAX_POST_SIZE: + self.close() + return + self.state = SCGIConnection.BODY + else: + self.close() + return # protocol violation + + if self.state == SCGIConnection.BODY: + if len(self.inbuff) >= self.reqlen: + self.body.write(self.inbuff[:self.reqlen]) + self.body.seek(0) + self.inbuff = "" + self.reqlen = 0 + self.environ.update(self._wsgi_headers()) + if "HTTP_CONTENT_TYPE" in self.environ: + self.environ["CONTENT_TYPE"] = \ + self.environ.pop("HTTP_CONTENT_TYPE") + if "HTTP_CONTENT_LENGTH" in self.environ: + del self.environ["HTTP_CONTENT_LENGTH"] # TODO: better way? + self.wsgihandler = iter(self.server.wsgiapp( + self.environ, self.start_response)) + self.state = SCGIConnection.REQ + else: + self.body.write(self.inbuff) + self.reqlen -= len(self.inbuff) + self.inbuff = "" + + def start_response(self, status, headers, exc_info=None): + if exc_info: + if self.outheaders == True: + try: + raise exc_info[0], exc_info[1], exc_info[2] + finally: + exc_info = None + assert self.outheaders != True # unsent + self.outheaders = (status, headers) + return self._wsgi_write + + def handle_write(self): + """asyncore interface""" + assert self.state >= SCGIConnection.REQ + if len(self.outbuff) < self.BLOCK_SIZE: + for data in self.wsgihandler: + self._try_send_headers() + self.outbuff += data + if len(self.outbuff) >= self.BLOCK_SIZE: + break + if len(self.outbuff) == 0: + if hasattr(self.wsgihandler, "close"): + self.wsgihandler.close() + self.close() + return + try: + n = self.send(self.outbuff[:self.BLOCK_SIZE]) + except socket.error: + if hasattr(self.wsgihandler, "close"): + self.wsgihandler.close() + self.close() + return + self.outbuff = self.outbuff[n:] + + def handle_close(self): + """asyncore interface""" + self.close() + +__all__.append("SCGIServer") +class SCGIServer(asyncore.dispatcher): + def __init__(self, wsgiapp, port, interface="localhost", error=sys.stderr): + asyncore.dispatcher.__init__(self) + self.wsgiapp = wsgiapp + self.error = error + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((interface, port)) + self.listen(5) + + def handle_accept(self): + r = self.accept() + if r is not None: + conn, addr = r + SCGIConnection(self, conn, addr) + + def run(self): + asyncore.loop() + |