summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarcel Hellkamp <marc@gsites.de>2010-03-08 00:03:31 +0100
committerMarcel Hellkamp <marc@gsites.de>2010-03-08 00:03:31 +0100
commit9a428ad87134b078c3e64181946cd62fd013bb8e (patch)
tree10c7ccb0d12db80802f3408b5b662c2691ca7bc5
parent9008fe14c3be7496c9f03c7c7844281c4461260a (diff)
parente4df5531cf26880961d912522c229ae414505323 (diff)
downloadbottle-9a428ad87134b078c3e64181946cd62fd013bb8e.tar.gz
Merge branch 'master' into stplunicode
-rwxr-xr-xapidoc/index.rst51
-rw-r--r--apidoc/intro.rst15
-rw-r--r--apidoc/sphinx/conf.py6
-rw-r--r--apidoc/sphinx/static/bottle.css39
-rw-r--r--apidoc/sphinx/static/favicon.icobin0 -> 686 bytes
-rw-r--r--apidoc/sphinx/static/logo_bg.pngbin0 -> 21285 bytes
-rw-r--r--apidoc/sphinx/static/logo_nav.pngbin0 -> 7883 bytes
-rwxr-xr-xbottle.py220
-rwxr-xr-xstartbottle.py208
-rw-r--r--test/test_outputfilter.py152
-rwxr-xr-xtest/test_wsgi.py61
-rwxr-xr-xtest/tools.py32
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
new file mode 100644
index 0000000..ad2abbe
--- /dev/null
+++ b/apidoc/sphinx/static/favicon.ico
Binary files differ
diff --git a/apidoc/sphinx/static/logo_bg.png b/apidoc/sphinx/static/logo_bg.png
new file mode 100644
index 0000000..49708ca
--- /dev/null
+++ b/apidoc/sphinx/static/logo_bg.png
Binary files differ
diff --git a/apidoc/sphinx/static/logo_nav.png b/apidoc/sphinx/static/logo_nav.png
new file mode 100644
index 0000000..798e479
--- /dev/null
+++ b/apidoc/sphinx/static/logo_nav.png
Binary files differ
diff --git a/bottle.py b/bottle.py
index 1b0fb34..ea698df 100755
--- a/bottle.py
+++ b/bottle.py
@@ -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',