diff options
author | Marcel Hellkamp <marc@gsites.de> | 2010-03-08 00:03:31 +0100 |
---|---|---|
committer | Marcel Hellkamp <marc@gsites.de> | 2010-03-08 00:03:31 +0100 |
commit | 9a428ad87134b078c3e64181946cd62fd013bb8e (patch) | |
tree | 10c7ccb0d12db80802f3408b5b662c2691ca7bc5 | |
parent | 9008fe14c3be7496c9f03c7c7844281c4461260a (diff) | |
parent | e4df5531cf26880961d912522c229ae414505323 (diff) | |
download | bottle-9a428ad87134b078c3e64181946cd62fd013bb8e.tar.gz |
Merge branch 'master' into stplunicode
-rwxr-xr-x | apidoc/index.rst | 51 | ||||
-rw-r--r-- | apidoc/intro.rst | 15 | ||||
-rw-r--r-- | apidoc/sphinx/conf.py | 6 | ||||
-rw-r--r-- | apidoc/sphinx/static/bottle.css | 39 | ||||
-rw-r--r-- | apidoc/sphinx/static/favicon.ico | bin | 0 -> 686 bytes | |||
-rw-r--r-- | apidoc/sphinx/static/logo_bg.png | bin | 0 -> 21285 bytes | |||
-rw-r--r-- | apidoc/sphinx/static/logo_nav.png | bin | 0 -> 7883 bytes | |||
-rwxr-xr-x | bottle.py | 220 | ||||
-rwxr-xr-x | startbottle.py | 208 | ||||
-rw-r--r-- | test/test_outputfilter.py | 152 | ||||
-rwxr-xr-x | test/test_wsgi.py | 61 | ||||
-rwxr-xr-x | test/tools.py | 32 |
12 files changed, 625 insertions, 159 deletions
diff --git a/apidoc/index.rst b/apidoc/index.rst index 3b272c7..5cb5b1a 100755 --- a/apidoc/index.rst +++ b/apidoc/index.rst @@ -3,17 +3,58 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Bottle's documentation! +.. highlight:: python + + +.. _mako: http://www.makotemplates.org/ +.. _cheetah: http://www.cheetahtemplate.org/ +.. _jinja2: http://jinja.pocoo.org/2/ +.. _paste: http://pythonpaste.org/ +.. _fapws3: http://github.com/william-os4y/fapws3 +.. _flup: http://trac.saddi.com/flup +.. _cherrypy: http://www.cherrypy.org/ +.. _WSGI: http://www.wsgi.org/wsgi/ +.. _Python: http://python.org/ +.. _testing: http://github.com/defnull/bottle/raw/master/bottle.py + + + +Bottle: Python Web Framework ================================== -Contents: +Bottle is a fast, simple and lightweight WSGI_ micro web-framework for Python_ with no external dependencies and packed into a single file. + +.. rubric:: Core Features + +* **Routes:** Mapping URLs to code with a simple but powerful pattern syntax. +* **Templates:** Fast build-in template engine and support for mako_, jinja2_ and cheetah_ templates. +* **Server:** Build-in HTTP development server and support for paste_, fapws3_, flup_, cherrypy_ or any other WSGI_ capable server. +* **Plug&Run:** All in a single file and no dependencies other than the Python standard library. + +.. rubric:: Download, Install and Dependencies + +You can install the latest stable release with ``easy_install -U bottle`` or just download the newest testing_ version into your project directory. There are no (hard [1]_) dependencies other than the Python standard library. Bottle runs with **Python 2.5+ and 3.x** (using 2to3) + +.. rubric:: Example + +This is a minimal bottle application serving a single URL:: + + from bottle import route, run + @route('/') + def index(): + return 'Hello World!' + run(host='localhost', port=8080) + +.. rubric:: Documentation .. toctree:: :maxdepth: 2 + intro + tutorial api stpl - tutorial + Indices and tables ================== @@ -22,3 +63,7 @@ Indices and tables * :ref:`modindex` * :ref:`search` +.. rubric:: Footnotes + +.. [1] Usage of the template or server adapter classes of course requires the corresponding template or server modules. + diff --git a/apidoc/intro.rst b/apidoc/intro.rst new file mode 100644 index 0000000..92c6a4f --- /dev/null +++ b/apidoc/intro.rst @@ -0,0 +1,15 @@ +How to start +============ + +.. glossary:: + + environ + A structure where information about all documents under the root is + saved, and used for cross-referencing. The environment is pickled + after the parsing stage, so that successive runs only need to read + and parse new and changed documents. + + source directory + The directory which, including its subdirectories, contains all + source files for one Sphinx project. + diff --git a/apidoc/sphinx/conf.py b/apidoc/sphinx/conf.py index 70ea2e5..8933047 100644 --- a/apidoc/sphinx/conf.py +++ b/apidoc/sphinx/conf.py @@ -112,18 +112,20 @@ html_theme = 'default' # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +html_logo = "static/logo_nav.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +html_favicon = "favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['static'] +html_style="bottle.css" + # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' diff --git a/apidoc/sphinx/static/bottle.css b/apidoc/sphinx/static/bottle.css new file mode 100644 index 0000000..5911717 --- /dev/null +++ b/apidoc/sphinx/static/bottle.css @@ -0,0 +1,39 @@ +@import url("default.css"); +/* +body { +background: #eee url("http://bottle.paws.de/logo_bg.png") no-repeat scroll 0 0; +} + +div.body { +border: 1px solid #ccc; +margin-right: 20px; +} + +div.body h1, div.body h2, div.body h3, div.body h4, div.body h5, div.body h6 { +border-bottom: 3px solid #003333; +border: 0px; +font-weight: bold; +margin: 20px -20px 10px; +padding: 3px 0 3px 10px; +}*/ + +div.body { + background: #fff url("logo_bg.png") no-repeat scroll right 3em; +} + +div.sphinxsidebar ul, div.sphinxsidebar ul ul, div.sphinxsidebar ul.want-points { + list-style-image:none; + list-style-position:inside; + list-style-type:disc; +} + +div.body dt { + font-weight: bold; +} + +pre { + background-color: #eee; + border: 1px solid #ddd; + border-width: 0 0 0 5px; + padding: 5px 10px; +} diff --git a/apidoc/sphinx/static/favicon.ico b/apidoc/sphinx/static/favicon.ico Binary files differnew file mode 100644 index 0000000..ad2abbe --- /dev/null +++ b/apidoc/sphinx/static/favicon.ico diff --git a/apidoc/sphinx/static/logo_bg.png b/apidoc/sphinx/static/logo_bg.png Binary files differnew file mode 100644 index 0000000..49708ca --- /dev/null +++ b/apidoc/sphinx/static/logo_bg.png diff --git a/apidoc/sphinx/static/logo_nav.png b/apidoc/sphinx/static/logo_nav.png Binary files differnew file mode 100644 index 0000000..798e479 --- /dev/null +++ b/apidoc/sphinx/static/logo_nav.png @@ -58,35 +58,33 @@ This is an example:: run(host='localhost', port=8080) """ + from __future__ import with_statement __author__ = 'Marcel Hellkamp' __version__ = '0.7.0a' __license__ = 'MIT' -import types -import sys +import base64 import cgi +import email.utils +import functools +import hmac +import inspect +import itertools import mimetypes import os -import os.path -from traceback import format_exc import re -import random +import subprocess +import sys +import thread import threading import time -import warnings -import email.utils + from Cookie import SimpleCookie -import subprocess -import thread from tempfile import TemporaryFile -import hmac -import base64 +from traceback import format_exc from urllib import quote as urlquote from urlparse import urlunsplit, urljoin -import functools -import itertools -import inspect try: from collections import MutableMapping as DictMixin @@ -115,10 +113,12 @@ if sys.version_info >= (3,0,0): # pragma: no cover # See Request.POST from io import BytesIO from io import TextIOWrapper + StringType = bytes def touni(x, enc='utf8'): # Convert anything to unicode (py3) return str(x, encoding=enc) if isinstance(x, bytes) else str(x) else: from StringIO import StringIO as BytesIO + from types import StringType TextIOWrapper = None def touni(x, enc='utf8'): # Convert anything to unicode (py2) return x if isinstance(x, unicode) else unicode(str(x), encoding=enc) @@ -155,17 +155,13 @@ class HTTPResponse(BottleException): class HTTPError(HTTPResponse): """ Used to generate an error page """ - def __init__(self, code=500, message='Unknown Error', exception=None, header=None): - super(HTTPError, self).__init__(message, code, header) + def __init__(self, code=500, output='Unknown Error', exception=None, traceback=None, header=None): + super(HTTPError, self).__init__(output, code, header) self.exception = exception + self.traceback = traceback - def __str__(self): - return ERROR_PAGE_TEMPLATE % { - 'status' : self.status, - 'url' : str(request.path), - 'error_name' : HTTP_CODES.get(self.status, 'Unknown').title(), - 'error_message' : str(self.output) - } + def __repr__(self): + return ''.join(ERROR_PAGE_TEMPLATE.render(e=self)) @@ -347,10 +343,21 @@ class Bottle(object): self.routes = Router() self.default_route = None self.error_handler = {} - self.jsondump = json_dumps if autojson and json_dumps else False self.catchall = catchall self.config = dict() self.serve = True + self.castfilter = [] + if autojson and json_dumps: + self.add_filter(dict, dict2json) + + def add_filter(self, ftype, func): + ''' Register a new output filter. Whenever bottle hits a handler output + matching `ftype`, `func` is applyed to it. ''' + if not isinstance(ftype, type): + raise TypeError("Expected type object, got %s" % type(ftype)) + self.castfilter = [(t, f) for (t, f) in self.castfilter if t != ftype] + self.castfilter.append((ftype, func)) + self.castfilter.sort() def match_url(self, path, method='GET'): """ Find a callback bound to a path and a specific HTTP method. @@ -406,10 +413,9 @@ class Bottle(object): return wrapper def handle(self, url, method, catchall=True): - """ Handle a single request. Return handler output, HTTPResponse or - HTTPError. If catchall is true, all exceptions thrown within a - handler function are catched and returned as HTTPError(500). - """ + """ Execute the handler bound to the specified url and method and return + its output. If catchall is true, exceptions are catched and returned as + HTTPError(500) objects. """ if not self.serve: return HTTPError(503, "Server stopped") @@ -421,18 +427,13 @@ class Bottle(object): return handler(**args) except HTTPResponse, e: return e - except (KeyboardInterrupt, SystemExit, MemoryError): - raise except Exception, e: - if not self.catchall: + if isinstance(e, (KeyboardInterrupt, SystemExit, MemoryError))\ + or not self.catchall: raise - err = "Unhandled Exception: %s\n" % (repr(e)) - if DEBUG: - err += '\n\nTraceback:\n' + format_exc(10) - request.environ['wsgi.errors'].write(err) - return HTTPError(500, err, e) + return HTTPError(500, 'Unhandled exception', e, format_exc(10)) - def _cast(self, out): + def _cast(self, out, peek=None): """ Try to convert the parameter into something WSGI compatible and set correct HTTP headers when possible. Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like, @@ -442,52 +443,59 @@ class Bottle(object): if not out: response.header['Content-Length'] = 0 return [] - # Join lists of byte or unicode strings (TODO: benchmark this against map) - if isinstance(out, list) and isinstance(out[0], (types.StringType, unicode)): + # Join lists of byte or unicode strings. Mixed lists are NOT supported + if isinstance(out, list) and isinstance(out[0], (StringType, unicode)): out = out[0][0:0].join(out) # b'abc'[0:0] -> b'' - # Convert dictionaries to JSON - if isinstance(out, dict) and self.jsondump: - response.content_type = 'application/json' - out = self.jsondump(out) # Encode unicode strings if isinstance(out, unicode): out = out.encode(response.charset) - # Byte Strings - if isinstance(out, types.StringType): + # Byte Strings are just returned + if isinstance(out, StringType): response.header['Content-Length'] = str(len(out)) return [out] - # HTTPError or HTTPException (recursive, because they may wrap anything) if isinstance(out, HTTPError): out.apply(response) - return self._cast(self.error_handler.get(out.status, str)(out)) + return self._cast(self.error_handler.get(out.status, repr)(out)) if isinstance(out, HTTPResponse): out.apply(response) return self._cast(out.output) - # Handle Files and other more complex iterables here... + # Filtered types (recursive, because they may return anything) + for testtype, filterfunc in self.castfilter: + if isinstance(out, testtype): + return self._cast(filterfunc(out)) + + # Cast Files into iterables if hasattr(out, 'read') and 'wsgi.file_wrapper' in request.environ: out = request.environ.get('wsgi.file_wrapper', lambda x, y: iter(lambda: x.read(y), ''))(out, 1024*64) - else: - out = iter(out) - # We peek into iterables to detect their inner type and to support - # generators as callbacks. They should not try to set any headers after - # their first yield statement. + + # Handle Iterables. We peek into them to detect their inner type. try: - while 1: + out = iter(out) + first = out.next() + while not first: first = out.next() - if first: break except StopIteration: - response.header['Content-Length'] = 0 - return [] - - if isinstance(first, types.StringType): + return self._cast('') + except HTTPResponse, e: + first = e + except Exception, e: + first = HTTPError(500, 'Unhandled exception', e, format_exc(10)) + if isinstance(e, (KeyboardInterrupt, SystemExit, MemoryError))\ + or not self.catchall: + raise + # These are the inner types allowed in iterator or generator objects. + if isinstance(first, HTTPResponse): + return self._cast(first) + if isinstance(first, StringType): return itertools.chain([first], out) - elif isinstance(first, unicode): + if isinstance(first, unicode): return itertools.imap(lambda x: x.encode(response.charset), itertools.chain([first], out)) - raise TypeError('Unsupported response type: %s' % type(first)) + return self._cast(HTTPError(500, 'Unsupported response type: %s'\ + % type(first))) def __call__(self, environ, start_response): """ The bottle WSGI-interface. """ @@ -498,24 +506,21 @@ class Bottle(object): out = self._cast(out) if response.status in (100, 101, 204, 304) or request.method == 'HEAD': out = [] # rfc2616 section 4.3 - if isinstance(out, list) and len(out) == 1: - response.header['Content-Length'] = str(len(out[0])) status = '%d %s' % (response.status, HTTP_CODES[response.status]) start_response(status, response.wsgiheader()) - # TODO: Yield here to catch errors in generator callbacks. return out except (KeyboardInterrupt, SystemExit, MemoryError): raise except Exception, e: if not self.catchall: raise - err = '<h1>Critial error while processing request: %s</h1>' \ + err = '<h1>Critical error while processing request: %s</h1>' \ % environ.get('PATH_INFO', '/') if DEBUG: err += '<h2>Error:</h2>\n<pre>%s</pre>\n' % repr(e) err += '<h2>Traceback:</h2>\n<pre>%s</pre>\n' % format_exc(10) environ['wsgi.errors'].write(err) #TODO: wsgi.error should not get html - start_response('500 INTERNAL SERVER ERROR', []) + start_response('500 INTERNAL SERVER ERROR', [('Content-Type', 'text/html')]) return [tob(err)] @@ -782,14 +787,6 @@ class Response(threading.local): # Data Structures -class BaseController(object): - _singleton = None - def __new__(cls, *a, **k): - if not cls._singleton: - cls._singleton = object.__new__(cls, *a, **k) - return cls._singleton - - class MultiDict(DictMixin): """ A dict that remembers old values for each key """ # collections.MutableMapping would be better for Python >= 2.6 @@ -850,8 +847,11 @@ class AppStack(list): # Module level functions -# BC: 0.6.4 and needed for run() -app = default_app = AppStack([Bottle()]) +# Output filter + +def dict2json(d): + response.content_type = 'application/json' + return json_dumps(d) def abort(code=500, text='Unknown Error: Appliction stopped.'): @@ -921,13 +921,20 @@ def url(routename, **kargs): # Utilities +def debug(mode=True): + """ Change the debug level. + There is only one debug level supported at the moment.""" + global DEBUG + DEBUG = bool(mode) + + def url(routename, **kargs): """ Return a named route filled with arguments """ return app().get_url(routename, **kargs) def parse_date(ims): - """ Parses rfc1123, rfc850 and asctime timestamps and returns UTC epoch. """ + """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """ try: ts = email.utils.parsedate_tz(ims) return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone @@ -936,12 +943,13 @@ def parse_date(ims): def parse_auth(header): + """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None""" try: method, data = header.split(None, 1) if method.lower() == 'basic': name, pwd = base64.b64decode(data).split(':', 1) return name, pwd - except (KeyError, ValueError, TypeError), a: + except (KeyError, ValueError, TypeError): return None @@ -1012,7 +1020,7 @@ def validate(**vkargs): abort(403, 'Missing parameter: %s' % key) try: kargs[key] = value(kargs[key]) - except ValueError, e: + except ValueError: abort(403, 'Wrong parameter format for: %s' % key) return func(**kargs) return wrapper @@ -1145,13 +1153,15 @@ class AppEngineServer(ServerAdapter): class TwistedServer(ServerAdapter): """ Untested. """ def run(self, handler): - import twisted.web.wsgi - import twisted.internet - resource = twisted.web.wsgi.WSGIResource(twisted.internet.reactor, - twisted.internet.reactor.getThreadPool(), handler) - site = server.Site(resource) - twisted.internet.reactor.listenTCP(self.port, self.host) - twisted.internet.reactor.run() + from twisted.web import server, wsgi + from twisted.python.threadpool import ThreadPool + from twisted.internet import reactor + thread_pool = ThreadPool() + thread_pool.start() + reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop) + server.Site(wsgi.WSGIResource(reactor, thread_pool, handler)) + reactor.listenTCP(self.port, self.host) + reactor.run() class DieselServer(ServerAdapter): @@ -1592,20 +1602,31 @@ HTTP_CODES = { """ A dict of known HTTP error and status codes """ -ERROR_PAGE_TEMPLATE = """<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> + +ERROR_PAGE_TEMPLATE = SimpleTemplate(""" +%import cgi +%from bottle import DEBUG, HTTP_CODES, request +%status_name = HTTP_CODES.get(e.status, 'Unknown').title() +<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> <html> <head> - <title>Error %(status)d: %(error_name)s</title> + <title>Error {{e.status}}: {{status_name}}</title> </head> <body> - <h1>Error %(status)d: %(error_name)s</h1> - <p>Sorry, the requested URL <tt>%(url)s</tt> caused an error:</p> - <pre> - %(error_message)s - </pre> + <h1>Error {{e.status}}: {{status_name}}</h1> + <p>Sorry, the requested URL <tt>{{cgi.escape(request.url)}}</tt> caused an error:</p> + <pre>{{cgi.escape(str(e.output))}}</pre> + %if DEBUG and e.exception: + <h2>Exception:</h2> + <pre>{{cgi.escape(repr(e.exception))}}</pre> + %end + %if DEBUG and e.traceback: + <h2>Traceback:</h2> + <pre>{{cgi.escape(e.traceback)}}</pre> + %end </body> </html> -""" +""") #TODO: use {{!bla}} instead of cgi.escape as soon as strlunicode is merged """ The HTML template used for error messages """ TRACEBACK_TEMPLATE = '<h2>Error:</h2>\n<pre>%s</pre>\n' \ @@ -1621,11 +1642,10 @@ response = Response() of :class:`Response` to generate the WSGI response. """ local = threading.local() +""" Thread-local namespace. Not used by Bottle, but could get handy """ -#TODO: Global and app local configuration (debug, defaults, ...) is a mess +# Initialize app stack (create first empty Bottle app) +# BC: 0.6.4 and needed for run() +app = default_app = AppStack() +app.push() -def debug(mode=True): - """ Change the debug level. - There is only one debug level supported at the moment.""" - global DEBUG - DEBUG = bool(mode) diff --git a/startbottle.py b/startbottle.py new file mode 100755 index 0000000..5243419 --- /dev/null +++ b/startbottle.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +""" This is a command line tool to load and serve bottle.py web applications. +""" + +import bottle +import logging +import sys +from optparse import OptionParser +import os +import subprocess +import time +import thread +import tempfile +import inspect + +logging.basicConfig() +log = logging.getLogger('bottle.starter') + +servernames = ['AutoServer'] +servernames.extend(x.__name__ for x in bottle.AutoServer.adapters) +reloading_servernames = ['WSGIRefServer'] + + +parser = OptionParser(usage="usage: %prog [options] module1 [module2 ...]") +parser.add_option("-s", "--server", + type="choice", + choices=servernames, + default=servernames[0], + help='Server backend: %s (default), %s or %s' + % (servernames[0], ", ".join(servernames[1:-1]), servernames[-1])) +parser.add_option("-a", "--host", + default='localhost', + help="Host address or name to bind to (default: localhost)") +parser.add_option("-r", "--reload", + default=False, + action='store_true', + help="Use auto reloading? (default: off)") +parser.add_option("-p", "--port", + type="int", + default=8080, + help="TCP Port to bind to (default: 8080)") +parser.add_option("-l", "--log", + help="Path to the logfile (default: stderr)") +parser.add_option("-d", "--debug", + action="store_true", + help="Log debug messages and include a stacktrace to HTTP-500 error pages (dangerous on public servers)") +parser.add_option("-v", "--verbose", + action="store_true", + help="Same as -d") + + +def terminate(process): + """ Kills a subprocess. """ + if hasattr(process, 'terminate'): + return process.terminate() + try: + import win32process + return win32process.TerminateProcess(process._handle, -1) + except ImportError: + import os + import signal + return os.kill(process.pid, signal.SIGTERM) + + +def set_exit_handler(func): + try: + import win32api + win32api.SetConsoleCtrlHandler(func, True) + except ImportError: + import signal + signal.signal(signal.SIGTERM, func) + + +class ModuleChecker(object): + def __init__(self): + self.files = {} + self.changed = [] + for module in sys.modules.values(): + try: + path = inspect.getsourcefile(module) + self.add(path) + except TypeError: + continue + + def add(self, path): + self.files[path] = self.mtime(path) + + def mtime(self, path): + return os.path.getmtime(path) if os.path.exists(path) else 0 + + def check(self): + for path, mtime in self.files.iteritems(): + newtime = self.mtime(path) + if mtime != newtime: + self.changed.append(path) + self.files[path] = newtime + return self.changed + + def reset(self): + self.changed = [] + + def loop(self, interval, callback): + while not self.check(): + print 'foo' + time.sleep(interval) + callback() + + +def run_child(**runargs): + """ Run as a child process and check for changed files in a separate thread. + As soon as a file change is detected, KeyboardInterrupt is thrown in + the main thread to exit the server loop. """ + checker = ModuleChecker() + thread.start_new_thread(checker.loop, (1, thread.interrupt_main), {}) + bottle.run(**runargs) # This blocks until KeyboardInterrupt + if checker.changed: + log.info("Changed files: %s; Reloading...", ', '.join(checker.changed)) + return 3 + return 0 + + +def run_observer(): + """ The observer loop: Start a child process and wait for it to terminate. + If the return code equals 3, restart it. Exit otherwise. + On an exception or SIGTERM, kill the child the hard way. """ + global child + child_argv = [sys.executable] + sys.argv + child_environ = os.environ.copy() + child_environ['BOTTLE_CHILD'] = 'true' + + def onexit(*argv): + if child.poll() == None: + terminate(child) + + set_exit_handler(onexit) + + while True: # Child restart loop + child = subprocess.Popen(child_argv, env=child_environ) + try: + code = child.wait() + if code != 3: + log.info("Child terminated with exit code %d. We do the same", code) + return code + except KeyboardInterrupt: + log.info("User exit. Waiting for Child to terminate...") + return child.wait() + except OSError, e: + # This happens on SIGTERM during child.wait(). We ignore it + onexit() + return child.wait() + except Exception, e: + onexit() + log.exception("Uh oh...") + raise + + +def main(argv): + opt, args = parser.parse_args(argv) + + # Logging + if opt.log: + log.addHandler(logging.handlers.FileHandler(opt.log)) + else: + logging.basicConfig() + + # DEBUG mode + if opt.verbose or opt.debug: + bottle.debug(True) + log.setLevel(logging.DEBUG) + + # Importing modules + sys.path.append('./') + if not args: + log.error("No modules specified") + return 1 + for mod in args: + try: + __import__(mod) + except ImportError: + log.exception("Failed to import module '%s' (ImportError)", mod) + return 1 + + # First case: We are a reloading observer process + if opt.reload and not os.environ.get('BOTTLE_CHILD'): + return run_observer() + + # Arguments for bottle.run() + runargs = {} + runargs['server'] = getattr(bottle, opt.server) + runargs['port'] = int(opt.port) + runargs['host'] = opt.host + + # Second case: We are a reloading child process + if os.environ.get('BOTTLE_CHILD'): + if runargs['server'] != bottle.WSGIRefServer: + log.warning("Currently only WSGIRefServer is known to support reloading") + runargs['server'] = bottle.WSGIRefServer + return run_child(**runargs) + + # Third case: We are not reloading a all + bottle.run(**runargs) + return 0 + + + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/test/test_outputfilter.py b/test/test_outputfilter.py new file mode 100644 index 0000000..7f8b412 --- /dev/null +++ b/test/test_outputfilter.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +'''Everything returned by Bottle()._cast() MUST be WSGI compatiple.''' + +import unittest +import bottle +from tools import ServerTestBase, tob +from StringIO import StringIO + +class TestOutputFilter(ServerTestBase): + ''' Tests for WSGI functionality, routing and output casting (decorators) ''' + + def test_bytes(self): + self.app.route('/')(lambda: tob('test')) + self.assertBody('test') + + def test_bytearray(self): + self.app.route('/')(lambda: map(tob, ['t', 'e', 'st'])) + self.assertBody('test') + + def test_emptylist(self): + self.app.route('/')(lambda: []) + self.assertBody('') + + def test_none(self): + self.app.route('/')(lambda: None) + self.assertBody('') + + def test_illegal(self): + self.app.route('/')(lambda: 1234) + self.assertStatus(500) + self.assertInBody('Unhandled exception') + + def test_error(self): + self.app.route('/')(lambda: 1/0) + self.assertStatus(500) + self.assertInBody('ZeroDivisionError') + + def test_fatal_error(self): + @self.app.route('/') + def test(): raise KeyboardInterrupt() + self.assertRaises(KeyboardInterrupt, self.assertStatus, 500) + + def test_file(self): + self.app.route('/')(lambda: StringIO('test')) + self.assertBody('test') + + def test_unicode(self): + self.app.route('/')(lambda: u'äöüß') + self.assertBody(u'äöüß'.encode('utf8')) + + self.app.route('/')(lambda: [u'äö',u'üß']) + self.assertBody(u'äöüß'.encode('utf8')) + + @self.app.route('/') + def test5(): + bottle.response.content_type='text/html; charset=iso-8859-15' + return u'äöüß' + self.assertBody(u'äöüß'.encode('iso-8859-15')) + + @self.app.route('/') + def test5(): + bottle.response.content_type='text/html' + return u'äöüß' + self.assertBody(u'äöüß'.encode('utf8')) + + def test_json(self): + self.app.route('/')(lambda: {'a': 1}) + self.assertBody(bottle.json_dumps({'a': 1})) + self.assertHeader('Content-Type','application/json') + + def test_custom(self): + self.app.route('/')(lambda: {'a': 1, 'b': 2}) + self.app.add_filter(dict, lambda x: x.keys()) + self.assertBody('ab') + + def test_generator_callback(self): + @self.app.route('/') + def test(): + bottle.response.header['Test-Header'] = 'test' + yield 'foo' + self.assertBody('foo') + self.assertHeader('Test-Header', 'test') + + def test_empty_generator_callback(self): + @self.app.route('/') + def test(): + yield + bottle.response.header['Test-Header'] = 'test' + self.assertBody('') + self.assertHeader('Test-Header', 'test') + + def test_error_in_generator_callback(self): + @self.app.route('/') + def test(): + yield 1/0 + self.assertStatus(500) + self.assertInBody('ZeroDivisionError') + + def test_fatal_error_in_generator_callback(self): + @self.app.route('/') + def test(): + yield + raise KeyboardInterrupt() + self.assertRaises(KeyboardInterrupt, self.assertStatus, 500) + + def test_httperror_in_generator_callback(self): + @self.app.route('/') + def test(): + yield + bottle.abort(404, 'teststring') + self.assertInBody('teststring') + self.assertInBody('Error 404: Not Found') + self.assertStatus(404) + + def test_httpresponse_in_generator_callback(self): + @self.app.route('/') + def test(): + yield bottle.HTTPResponse('test') + self.assertBody('test') + + def test_unicode_generator_callback(self): + @self.app.route('/') + def test(): + yield u'äöüß' + self.assertBody(u'äöüß'.encode('utf8')) + + def test_invalid_generator_callback(self): + @self.app.route('/') + def test(): + yield 1234 + self.assertStatus(500) + self.assertInBody('Unsupported response type') + + def test_cookie(self): + """ WSGI: Cookies """ + @bottle.route('/cookie') + def test(): + bottle.response.COOKIES['a']="a" + bottle.response.set_cookie('b', 'b') + bottle.response.set_cookie('c', 'c', path='/') + return 'hello' + try: + c = self.urlopen('/cookie')['header'].get_all('Set-Cookie', '') + except: + c = self.urlopen('/cookie')['header'].get('Set-Cookie', '').split(',') + c = [x.strip() for x in c] + self.assertTrue('a=a' in c) + self.assertTrue('b=b' in c) + self.assertTrue('c=c; Path=/' in c) + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_wsgi.py b/test/test_wsgi.py index 57ec545..cad526a 100755 --- a/test/test_wsgi.py +++ b/test/test_wsgi.py @@ -50,14 +50,14 @@ class TestWsgi(ServerTestBase): self.assertStatus(200, '/any', method='HEAD') self.assertBody('test', '/any', method='GET') self.assertBody('test', '/any', method='POST') - self.assertBody('test', '/any', method='1337') + self.assertBody('test', '/any', method='DELETE') @bottle.route('/any', method='GET') def test2(): return 'test2' self.assertBody('test2', '/any', method='GET') @bottle.route('/any', method='POST') def test2(): return 'test3' self.assertBody('test3', '/any', method='POST') - self.assertBody('test', '/any', method='1337') + self.assertBody('test', '/any', method='DELETE') def test_500(self): """ WSGI: Exceptions within handler code (HTTP 500) """ @@ -106,50 +106,19 @@ class TestWsgi(ServerTestBase): self.assertStatus(303, '/') self.assertHeader('Location', 'http://127.0.0.1/yes', '/') - def test_casting(self): - """ WSGI: Output Casting (strings an lists) """ - @bottle.route('/str') - def test(): return 'test' - self.assertBody('test', '/str') - @bottle.route('/list') - def test2(): return ['t', 'e', 'st'] - self.assertBody('test', '/list') - @bottle.route('/empty') - def test3(): return [] - self.assertBody('', '/empty') - @bottle.route('/none') - def test4(): return None - self.assertBody('', '/none') - @bottle.route('/bad') - def test5(): return 12345 - self.assertStatus(500,'/bad') - - def test_file(self): - """ WSGI: Output Casting (files) """ - @bottle.route('/file') - def test(): return StringIO('test') - self.assertBody('test', '/file') - - def test_unicode(self): - """ WSGI: Test Unicode support """ - @bottle.route('/unicode') - def test3(): return u'äöüß' - @bottle.route('/unicode2') - def test4(): return [u'äöüß'] - @bottle.route('/unicode3') - def test5(): - bottle.response.content_type='text/html; charset=iso-8859-15' - return u'äöüß' - self.assertBody(u'äöüß'.encode('utf8'), '/unicode') - self.assertBody(u'äöüß'.encode('utf8'), '/unicode2') - self.assertBody(u'äöüß'.encode('iso-8859-15'), '/unicode3') - - def test_json(self): - """ WSGI: Autojson feature """ - @bottle.route('/json') - def test(): return {'a': 1} - self.assertBody(self.app.jsondump({'a': 1}), '/json') - self.assertHeader('Content-Type','application/json', '/json') + def test_generator_callback(self): + @bottle.route('/yield') + def test(): + bottle.response.header['Test-Header'] = 'test' + yield 'foo' + @bottle.route('/yield_nothing') + def test2(): + yield + bottle.response.header['Test-Header'] = 'test' + self.assertBody('foo', '/yield') + self.assertHeader('Test-Header', 'test', '/yield') + self.assertBody('', '/yield_nothing') + self.assertHeader('Test-Header', 'test', '/yield_nothing') def test_cookie(self): """ WSGI: Cookies """ diff --git a/test/tools.py b/test/tools.py index 3e318a5..b9a1d5b 100755 --- a/test/tools.py +++ b/test/tools.py @@ -9,6 +9,8 @@ import unittest import wsgiref import wsgiref.simple_server import wsgiref.util +import wsgiref.validate + from StringIO import StringIO try: from io import BytesIO @@ -26,6 +28,7 @@ class ServerTestBase(unittest.TestCase): self.port = 8080 self.host = 'localhost' self.app = bottle.app.push() + self.wsgiapp = wsgiref.validate.validator(self.app) def urlopen(self, path, method='GET', post='', env=None): result = {'code':0, 'status':'error', 'header':{}, 'body':tob('')} @@ -42,16 +45,21 @@ class ServerTestBase(unittest.TestCase): wsgiref.util.setup_testing_defaults(env) env['REQUEST_METHOD'] = method.upper().strip() env['PATH_INFO'] = path + env['QUERY_STRING'] = '' if post: env['REQUEST_METHOD'] = 'POST' - env['CONTENT_LENGTH'] = len(tob(post)) + env['CONTENT_LENGTH'] = str(len(tob(post))) env['wsgi.input'].write(tob(post)) env['wsgi.input'].seek(0) - for part in self.app(env, start_response): + response = self.wsgiapp(env, start_response) + for part in response: try: result['body'] += part except TypeError: raise TypeError('WSGI app yielded non-byte object %s', type(part)) + if hasattr(response, 'close'): + response.close() + del response return result def postmultipart(self, path, fields, files): @@ -60,21 +68,29 @@ class ServerTestBase(unittest.TestCase): def tearDown(self): bottle.app.pop() - def assertStatus(self, code, route, **kargs): + def assertStatus(self, code, route='/', **kargs): self.assertEqual(code, self.urlopen(route, **kargs)['code']) - def assertBody(self, body, route, **kargs): + def assertBody(self, body, route='/', **kargs): self.assertEqual(tob(body), self.urlopen(route, **kargs)['body']) - def assertInBody(self, body, route, **kargs): - self.assertTrue(tob(body) in self.urlopen(route, **kargs)['body']) + def assertInBody(self, body, route='/', **kargs): + body = self.urlopen(route, **kargs)['body'] + if tob(body) not in body: + self.fail('The search pattern "%s" is not included in body:\n%s' % (body, body)) - def assertHeader(self, name, value, route, **kargs): + def assertHeader(self, name, value, route='/', **kargs): self.assertEqual(value, self.urlopen(route, **kargs)['header'].get(name)) - def assertHeaderAny(self, name, route, **kargs): + def assertHeaderAny(self, name, route='/', **kargs): self.assertTrue(self.urlopen(route, **kargs)['header'].get(name, None)) + def assertInError(self, search, route='/', **kargs): + bottle.request.environ['wsgi.errors'].errors.seek(0) + err = bottle.request.environ['wsgi.errors'].errors.read() + if search not in err: + self.fail('The search pattern "%s" is not included in wsgi.error: %s' % (search, err)) + def multipart_environ(fields, files): boundary = str(uuid.uuid1()) env = {'REQUEST_METHOD':'POST', |