17 |
18 |
19 | """ % exception
20 | response = Response(html_output, status=500)
21 | return response.send(request._start_response)
22 |
23 | @get('/hello')
24 | def hello(request):
25 | return 'Hello errors!'
26 |
27 | @get('/test_404')
28 | def test_404(request):
29 | raise NotFound('Not here, sorry.')
30 | return 'This should never happen.'
31 |
32 | @get('/test_500')
33 | def test_500(request):
34 | raise AppError('Oops.')
35 | return 'This should never happen either.'
36 |
37 | @get('/test_other')
38 | def test_other(request):
39 | raise RuntimeError('Oops.')
40 | return 'This should never happen either either.'
41 |
42 | @get('/test_403')
43 | def test_403(request):
44 | raise Forbidden('No soup for you!')
45 | return 'This should never happen either either either.'
46 |
47 | @get('/test_redirect')
48 | def test_redirect(request):
49 | raise Redirect('/hello')
50 |
51 | run_itty()
52 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | =======
2 | itty.py
3 | =======
4 |
5 | The itty-bitty Python web framework.
6 |
7 | ``itty.py`` is a little experiment, an attempt at a Sinatra_ influenced
8 | micro-framework that does just enough to be useful and nothing more.
9 |
10 | Currently supports:
11 |
12 | * Routing
13 | * Basic responses
14 | * Content-types
15 | * HTTP Status codes
16 | * URL Parameters
17 | * Basic GET/POST/PUT/DELETE support
18 | * User-definable error handlers
19 | * Redirect support
20 | * File uploads
21 | * Header support
22 | * Static media serving
23 |
24 | Beware! If you're looking for a proven, enterprise-ready framework, you're in
25 | the wrong place. But it sure is a lot of fun.
26 |
27 | .. _Sinatra: http://sinatrarb.com/
28 |
29 |
30 | Example
31 | =======
32 |
33 | ::
34 |
35 | from itty import get, run_itty
36 |
37 | @get('/')
38 | def index(request):
39 | return 'Hello World!'
40 |
41 | run_itty()
42 |
43 | See ``examples/`` for more usages.
44 |
45 |
46 | Other Sources
47 | =============
48 |
49 | A couple of bits have been borrowed from other sources:
50 |
51 | * Django
52 |
53 | * HTTP_MAPPINGS
54 |
55 | * Armin Ronacher's blog (http://lucumr.pocoo.org/2007/5/21/getting-started-with-wsgi)
56 |
57 | * How to get started with WSGI
58 |
59 |
60 | Thanks
61 | ======
62 |
63 | Thanks go out to Matt Croydon & Christian Metts for putting me up to this late
64 | at night. The joking around has become reality. :)
--------------------------------------------------------------------------------
/benchmarks.rst:
--------------------------------------------------------------------------------
1 | ==========
2 | Benchmarks
3 | ==========
4 |
5 | There are lies, damned lies and statistics... and benchmarks.
6 |
7 | Run using ``siege`` on the ``examples/alternate_servers.py`` file. Performed on
8 | a 2008 MacBook Pro.
9 |
10 | * ``siege -c 1 -b http://localhost:8080``
11 | * ``siege -c 10 -b http://localhost:8080``
12 | * ``siege -c 100 -b http://localhost:8080``
13 |
14 | +-----------+----------+------------+-------------+
15 | | Server | 1 Client | 10 Clients | 100 Clients |
16 | +-----------+----------+------------+-------------+
17 | | wsgiref | 2 | 17 | N/A |
18 | +-----------+----------+------------+-------------+
19 | | twisted | 348 | 426 | 408 |
20 | +-----------+----------+------------+-------------+
21 | | diesel | 901 | 1373 | 1295 |
22 | +-----------+----------+------------+-------------+
23 | | tornado | 962 | 1427 | 1380 |
24 | +-----------+----------+------------+-------------+
25 | | cherrypy | ??? | ??? | ??? |
26 | +-----------+----------+------------+-------------+
27 | | flup | ??? | ??? | ??? |
28 | +-----------+----------+------------+-------------+
29 | | paste | ??? | ??? | ??? |
30 | +-----------+----------+------------+-------------+
31 | | appengine | ??? | ??? | ??? |
32 | +-----------+----------+------------+-------------+
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2009, Daniel Lindsley.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of itty nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/examples/static_files.py:
--------------------------------------------------------------------------------
1 | from itty import *
2 |
3 | MY_ROOT = os.path.join(os.path.dirname(__file__), 'media')
4 |
5 |
6 | @get('/')
7 | def index(request):
8 | return ''
9 |
10 | # To serve static files, simply setup a standard @get method. You should
11 | # capture the filename/path and get the content-type. If your media root is
12 | # different than where your ``itty.py`` lives, manually setup your root
13 | # directory as well. Finally, use the ``static_file`` helper to serve up the
14 | # file.
15 | @get('/media/(?P.+)')
16 | def my_media(request, filename):
17 | output = static_file(filename, root=MY_ROOT)
18 | return Response(output, content_type=content_type(filename))
19 |
20 |
21 | # Alternative, if sane-ish defaults are good enough for you, you can use the
22 | # ``serve_static_file`` handler to do the heavy lifting for you. For example:
23 | @get('/simple/')
24 | def simple(request):
25 | return """
26 |
27 |
28 | Simple CSS
29 |
30 |
31 |
32 |
Simple CSS is Simple!
33 |
Simple reset here.
34 |
35 |
36 | """
37 |
38 | # By default, the ``serve_static_file`` will try to guess the correct content
39 | # type. If needed, you can enforce a content type by using the
40 | # ``force_content_type`` kwarg (i.e. ``force_content_type='image/jpg'`` on a
41 | # directory of user uploaded images).
42 | @get('/simple_media/(?P.+)')
43 | def simple_media(request, filename):
44 | return serve_static_file(request, filename, root=MY_ROOT)
45 |
46 |
47 | run_itty()
48 |
--------------------------------------------------------------------------------
/itty.py:
--------------------------------------------------------------------------------
1 | """
2 | The itty-bitty Python web framework.
3 |
4 | Totally ripping off Sintra, the Python way. Very useful for small applications,
5 | especially web services. Handles basic HTTP methods (PUT/DELETE too!). Errs on
6 | the side of fun and terse.
7 |
8 |
9 | Example Usage::
10 |
11 | from itty import get, run_itty
12 |
13 | @get('/')
14 | def index(request):
15 | return 'Hello World!'
16 |
17 | run_itty()
18 |
19 |
20 | Thanks go out to Matt Croydon & Christian Metts for putting me up to this late
21 | at night. The joking around has become reality. :)
22 | """
23 | import base64
24 | import cgi
25 | import datetime
26 | import hashlib
27 | import hmac
28 | import logging
29 | import mimetypes
30 | import numbers
31 | import os
32 | import re
33 | import StringIO
34 | import sys
35 | import time
36 | import traceback
37 | try:
38 | from urlparse import parse_qs
39 | except ImportError:
40 | from cgi import parse_qs
41 | try:
42 | import Cookie
43 | except ImportError:
44 | import http.cookies as Cookie
45 |
46 | __author__ = 'Daniel Lindsley'
47 | __version__ = ('0', '8', '2')
48 | __license__ = 'BSD'
49 |
50 |
51 | REQUEST_MAPPINGS = {
52 | 'GET': [],
53 | 'POST': [],
54 | 'PUT': [],
55 | 'DELETE': [],
56 | }
57 |
58 | ERROR_HANDLERS = {}
59 |
60 | MEDIA_ROOT = os.path.join(os.path.dirname(__file__), 'media')
61 |
62 | HTTP_MAPPINGS = {
63 | 100: 'CONTINUE',
64 | 101: 'SWITCHING PROTOCOLS',
65 | 200: 'OK',
66 | 201: 'CREATED',
67 | 202: 'ACCEPTED',
68 | 203: 'NON-AUTHORITATIVE INFORMATION',
69 | 204: 'NO CONTENT',
70 | 205: 'RESET CONTENT',
71 | 206: 'PARTIAL CONTENT',
72 | 300: 'MULTIPLE CHOICES',
73 | 301: 'MOVED PERMANENTLY',
74 | 302: 'FOUND',
75 | 303: 'SEE OTHER',
76 | 304: 'NOT MODIFIED',
77 | 305: 'USE PROXY',
78 | 306: 'RESERVED',
79 | 307: 'TEMPORARY REDIRECT',
80 | 400: 'BAD REQUEST',
81 | 401: 'UNAUTHORIZED',
82 | 402: 'PAYMENT REQUIRED',
83 | 403: 'FORBIDDEN',
84 | 404: 'NOT FOUND',
85 | 405: 'METHOD NOT ALLOWED',
86 | 406: 'NOT ACCEPTABLE',
87 | 407: 'PROXY AUTHENTICATION REQUIRED',
88 | 408: 'REQUEST TIMEOUT',
89 | 409: 'CONFLICT',
90 | 410: 'GONE',
91 | 411: 'LENGTH REQUIRED',
92 | 412: 'PRECONDITION FAILED',
93 | 413: 'REQUEST ENTITY TOO LARGE',
94 | 414: 'REQUEST-URI TOO LONG',
95 | 415: 'UNSUPPORTED MEDIA TYPE',
96 | 416: 'REQUESTED RANGE NOT SATISFIABLE',
97 | 417: 'EXPECTATION FAILED',
98 | 500: 'INTERNAL SERVER ERROR',
99 | 501: 'NOT IMPLEMENTED',
100 | 502: 'BAD GATEWAY',
101 | 503: 'SERVICE UNAVAILABLE',
102 | 504: 'GATEWAY TIMEOUT',
103 | 505: 'HTTP VERSION NOT SUPPORTED',
104 | }
105 |
106 |
107 | class RequestError(Exception):
108 | """A base exception for HTTP errors to inherit from."""
109 | status = 404
110 |
111 | def __init__(self, message, hide_traceback=False):
112 | super(RequestError, self).__init__(message)
113 | self.hide_traceback = hide_traceback
114 |
115 |
116 | class Forbidden(RequestError):
117 | status = 403
118 |
119 |
120 | class NotFound(RequestError):
121 | status = 404
122 |
123 | def __init__(self, message, hide_traceback=True):
124 | super(NotFound, self).__init__(message)
125 | self.hide_traceback = hide_traceback
126 |
127 |
128 | class AppError(RequestError):
129 | status = 500
130 |
131 |
132 | class Redirect(RequestError):
133 | """
134 | Redirects the user to a different URL.
135 |
136 | Slightly different than the other HTTP errors, the Redirect is less
137 | 'OMG Error Occurred' and more 'let's do something exceptional'. When you
138 | redirect, you break out of normal processing anyhow, so it's a very similar
139 | case."""
140 | status = 302
141 | url = ''
142 |
143 | def __init__(self, url):
144 | self.url = url
145 | self.args = ["Redirecting to '%s'..." % self.url]
146 |
147 |
148 | class lazyproperty(object):
149 | """A property whose value is computed only once. """
150 | def __init__(self, function):
151 | self._function = function
152 |
153 | def __get__(self, obj, _=None):
154 | if obj is None:
155 | return self
156 |
157 | value = self._function(obj)
158 | setattr(obj, self._function.func_name, value)
159 | return value
160 |
161 | if hasattr(hmac, 'compare_digest'): # python 3.3
162 | _time_independent_equals = hmac.compare_digest
163 | else:
164 | def _time_independent_equals(a, b):
165 | if len(a) != len(b):
166 | return False
167 | result = 0
168 | if isinstance(a[0], int): # python3 byte strings
169 | for x, y in zip(a, b):
170 | result |= x ^ y
171 | else: # python2
172 | for x, y in zip(a, b):
173 | result |= ord(x) ^ ord(y)
174 | return result == 0
175 |
176 | if type('') is not type(b''):
177 | def u(s):
178 | return s
179 | bytes_type = bytes
180 | unicode_type = str
181 | basestring_type = str
182 | else:
183 | def u(s):
184 | return s.decode('unicode_escape')
185 | bytes_type = str
186 | unicode_type = unicode
187 | basestring_type = basestring
188 |
189 | _UTF8_TYPES = (bytes_type, type(None))
190 |
191 |
192 | def utf8(value):
193 | """Converts a string argument to a byte string.
194 | """
195 | if isinstance(value, _UTF8_TYPES):
196 | return value
197 | assert isinstance(value, unicode_type)
198 | return value.encode("utf-8")
199 |
200 | _TO_UNICODE_TYPES = (unicode_type, type(None))
201 |
202 |
203 | def to_unicode(value):
204 | """Converts a string argument to a unicode string.
205 | """
206 | if isinstance(value, _TO_UNICODE_TYPES):
207 | return value
208 | assert isinstance(value, bytes_type)
209 | return value.decode("utf-8")
210 |
211 | _unicode = to_unicode
212 |
213 | if str is unicode_type:
214 | native_str = to_unicode
215 | else:
216 | native_str = utf8
217 |
218 |
219 | def format_timestamp(ts):
220 | """Formats a timestamp in the format used by HTTP.
221 | """
222 | if isinstance(ts, (tuple, time.struct_time)):
223 | pass
224 | elif isinstance(ts, datetime.datetime):
225 | ts = ts.utctimetuple()
226 | elif isinstance(ts, numbers.Real):
227 | ts = time.gmtime(ts)
228 | else:
229 | raise TypeError("unknown timestamp type: %r" % ts)
230 | return time.strftime("%a, %d %b %Y %H:%M:%S GMT", ts)
231 |
232 |
233 | def create_signed_value(secret, name, value):
234 | timestamp = utf8(str(int(time.time())))
235 | value = base64.b64encode(utf8(value))
236 | signature = _create_signature(secret, name, value, timestamp)
237 | value = b"|".join([value, timestamp, signature])
238 | return value
239 |
240 |
241 | def decode_signed_value(secret, name, value, max_age_days=31):
242 | if not value:
243 | return None
244 | parts = utf8(value).split(b"|")
245 | if len(parts) != 3:
246 | return None
247 | signature = _create_signature(secret, name, parts[0], parts[1])
248 | if not _time_independent_equals(parts[2], signature):
249 | logging.warning("Invalid cookie signature %r", value)
250 | return None
251 | timestamp = int(parts[1])
252 | if timestamp < time.time() - max_age_days * 86400:
253 | logging.warning("Expired cookie %r", value)
254 | return None
255 | if timestamp > time.time() + 31 * 86400:
256 | # _cookie_signature does not hash a delimiter between the
257 | # parts of the cookie, so an attacker could transfer trailing
258 | # digits from the payload to the timestamp without altering the
259 | # signature. For backwards compatibility, sanity-check timestamp
260 | # here instead of modifying _cookie_signature.
261 | logging.warning("Cookie timestamp in future; possible tampering %r", value)
262 | return None
263 | if parts[1].startswith(b"0"):
264 | logging.warning("Tampered cookie %r", value)
265 | return None
266 | try:
267 | return base64.b64decode(parts[0])
268 | except Exception:
269 | return None
270 |
271 |
272 | def _create_signature(secret, *parts):
273 | hash = hmac.new(utf8(secret), digestmod=hashlib.sha1)
274 | for part in parts:
275 | hash.update(utf8(part))
276 | return utf8(hash.hexdigest())
277 |
278 |
279 | class HTTPHeaders(dict):
280 | """A dictionary that maintains Http-Header-Case for all keys.
281 | """
282 | def __init__(self, *args, **kwargs):
283 | dict.__init__(self)
284 | self._as_list = {}
285 | self._last_key = None
286 | if (len(args) == 1 and len(kwargs) == 0 and
287 | isinstance(args[0], HTTPHeaders)):
288 | for k, v in args[0].get_all():
289 | self.add(k, v)
290 | else:
291 | self.update(*args, **kwargs)
292 |
293 | def add(self, name, value):
294 | """Adds a new value for the given key."""
295 | norm_name = HTTPHeaders._normalize_name(name)
296 | self._last_key = norm_name
297 | if norm_name in self:
298 | dict.__setitem__(self, norm_name,
299 | native_str(self[norm_name]) + ',' +
300 | native_str(value))
301 | self._as_list[norm_name].append(value)
302 | else:
303 | self[norm_name] = value
304 |
305 | def get_list(self, name):
306 | """Returns all values for the given header as a list."""
307 | norm_name = HTTPHeaders._normalize_name(name)
308 | return self._as_list.get(norm_name, [])
309 |
310 | def get_all(self):
311 | """Returns an iterable of all (name, value) pairs.
312 |
313 | If a header has multiple values, multiple pairs will be
314 | returned with the same name.
315 | """
316 | for name, list in self._as_list.items():
317 | for value in list:
318 | yield (name, value)
319 |
320 | def parse_line(self, line):
321 | """Updates the dictionary with a single header line.
322 | """
323 | if line[0].isspace():
324 | # continuation of a multi-line header
325 | new_part = ' ' + line.lstrip()
326 | self._as_list[self._last_key][-1] += new_part
327 | dict.__setitem__(self, self._last_key,
328 | self[self._last_key] + new_part)
329 | else:
330 | name, value = line.split(":", 1)
331 | self.add(name, value.strip())
332 |
333 | @classmethod
334 | def parse(cls, headers):
335 | """Returns a dictionary from HTTP header text.
336 | """
337 | h = cls()
338 | for line in headers.splitlines():
339 | if line:
340 | h.parse_line(line)
341 | return h
342 |
343 | def __setitem__(self, name, value):
344 | norm_name = HTTPHeaders._normalize_name(name)
345 | dict.__setitem__(self, norm_name, value)
346 | self._as_list[norm_name] = [value]
347 |
348 | def __getitem__(self, name):
349 | return dict.__getitem__(self, HTTPHeaders._normalize_name(name))
350 |
351 | def __delitem__(self, name):
352 | norm_name = HTTPHeaders._normalize_name(name)
353 | dict.__delitem__(self, norm_name)
354 | del self._as_list[norm_name]
355 |
356 | def __contains__(self, name):
357 | norm_name = HTTPHeaders._normalize_name(name)
358 | return dict.__contains__(self, norm_name)
359 |
360 | def get(self, name, default=None):
361 | return dict.get(self, HTTPHeaders._normalize_name(name), default)
362 |
363 | def update(self, *args, **kwargs):
364 | for k, v in dict(*args, **kwargs).items():
365 | self[k] = v
366 |
367 | def copy(self):
368 | return HTTPHeaders(self)
369 |
370 | _NORMALIZED_HEADER_RE = re.compile(
371 | r'^[A-Z0-9][a-z0-9]*(-[A-Z0-9][a-z0-9]*)*$')
372 | _normalized_headers = {}
373 |
374 | @staticmethod
375 | def _normalize_name(name):
376 | """Converts a name to Http-Header-Case.
377 | """
378 | try:
379 | return HTTPHeaders._normalized_headers[name]
380 | except KeyError:
381 | if HTTPHeaders._NORMALIZED_HEADER_RE.match(name):
382 | normalized = name
383 | else:
384 | normalized = "-".join(
385 | [w.capitalize() for w in name.split("-")])
386 | HTTPHeaders._normalized_headers[name] = normalized
387 | return normalized
388 |
389 |
390 | class Request(object):
391 | """An object to wrap the environ bits in a friendlier way."""
392 | GET = {}
393 |
394 | def __init__(self, environ, start_response):
395 | self._environ = environ
396 | self._start_response = start_response
397 | self.setup_self()
398 |
399 | def setup_self(self):
400 | self.path = add_slash(self._environ.get('PATH_INFO', ''))
401 | self.method = self._environ.get('REQUEST_METHOD', 'GET').upper()
402 | self.query = self._environ.get('QUERY_STRING', '')
403 | self.content_length = 0
404 | self.headers = HTTPHeaders()
405 | if self._environ.get("CONTENT_TYPE"):
406 | self.headers["Content-Type"] = self._environ["CONTENT_TYPE"]
407 | if self._environ.get("CONTENT_LENGTH"):
408 | self.headers["Content-Length"] = self._environ["CONTENT_LENGTH"]
409 | for key in self._environ:
410 | if key.startswith("HTTP_"):
411 | self.headers[key[5:].replace("_", "-")] = self._environ[key]
412 | try:
413 | self.content_length = int(self._environ.get('CONTENT_LENGTH', '0'))
414 | except ValueError:
415 | pass
416 |
417 | self.GET = self.build_get_dict()
418 |
419 | def __getattr__(self, name):
420 | """
421 | Allow accesses of the environment if we don't already have an attribute
422 | for. This lets you do things like::
423 |
424 | script_name = request.SCRIPT_NAME
425 | """
426 | return self._environ[name]
427 |
428 | @lazyproperty
429 | def POST(self):
430 | return self.build_complex_dict()
431 |
432 | @lazyproperty
433 | def PUT(self):
434 | return self.build_complex_dict()
435 |
436 | @lazyproperty
437 | def body(self):
438 | """Content of the request."""
439 | return self._environ['wsgi.input'].read(self.content_length)
440 |
441 | @property
442 | def cookies(self):
443 | """A dictionary of Cookie.Morsel objects."""
444 | if not hasattr(self, "_cookies"):
445 | self._cookies = Cookie.SimpleCookie()
446 | if "Cookie" in self.headers:
447 | try:
448 | self._cookies.load(
449 | native_str(self.headers["Cookie"]))
450 | except Exception:
451 | self._cookies = None
452 | return self._cookies
453 |
454 | def get_cookie(self, name, default=None):
455 | """Gets the value of the cookie with the given name, else default."""
456 | if self.cookies is not None and name in self.cookies:
457 | return self.cookies[name].value
458 | return default
459 |
460 | def get_secure_cookie(self, name, value=None, max_age_days=31):
461 | """Returns the given signed cookie if it validates, or None.
462 | """
463 | if value is None:
464 | value = self.get_cookie(name)
465 | return decode_signed_value(COOKIE_SECRET, name, value,
466 | max_age_days=max_age_days)
467 |
468 | def build_get_dict(self):
469 | """Takes GET data and rips it apart into a dict."""
470 | raw_query_dict = parse_qs(self.query, keep_blank_values=1)
471 | query_dict = {}
472 |
473 | for key, value in raw_query_dict.items():
474 | if len(value) <= 1:
475 | query_dict[key] = value[0]
476 | else:
477 | # Since it's a list of multiple items, we must have seen more than
478 | # one item of the same name come in. Store all of them.
479 | query_dict[key] = value
480 |
481 | return query_dict
482 |
483 | def build_complex_dict(self):
484 | """Takes POST/PUT data and rips it apart into a dict."""
485 | raw_data = cgi.FieldStorage(fp=StringIO.StringIO(self.body), environ=self._environ)
486 | query_dict = {}
487 |
488 | for field in raw_data:
489 | if isinstance(raw_data[field], list):
490 | # Since it's a list of multiple items, we must have seen more than
491 | # one item of the same name come in. Store all of them.
492 | query_dict[field] = [fs.value for fs in raw_data[field]]
493 | elif raw_data[field].filename:
494 | # We've got a file.
495 | query_dict[field] = raw_data[field]
496 | else:
497 | query_dict[field] = raw_data[field].value
498 |
499 | return query_dict
500 |
501 |
502 | class Response(object):
503 |
504 | def __init__(self, output, headers=None, status=200, content_type='text/html'):
505 | self.output = output
506 | self.content_type = content_type
507 | self.status = status
508 | self.headers = HTTPHeaders()
509 |
510 | if headers and isinstance(headers, HTTPHeaders):
511 | self.headers = headers
512 | if headers and isinstance(headers, list):
513 | for (key, value) in headers:
514 | self.headers.add(key, value)
515 |
516 | def add_header(self, key, value):
517 | self.headers.add(key, value)
518 |
519 | def set_cookie(self, name, value, domain=None, expires=None, path="/",
520 | expires_days=None, **kwargs):
521 | """Sets the given cookie name/value with the given options.
522 | """
523 | name = native_str(name)
524 | value = native_str(value)
525 | if re.search(r"[\x00-\x20]", name + value):
526 | raise ValueError("Invalid cookie %r: %r" % (name, value))
527 | if not hasattr(self, "_new_cookie"):
528 | self._new_cookie = Cookie.SimpleCookie()
529 | if name in self._new_cookie:
530 | del self._new_cookie[name]
531 | self._new_cookie[name] = value
532 | morsel = self._new_cookie[name]
533 | if domain:
534 | morsel["domain"] = domain
535 | if expires_days is not None and not expires:
536 | expires = datetime.datetime.utcnow() + datetime.timedelta(
537 | days=expires_days)
538 | if expires:
539 | morsel["expires"] = format_timestamp(expires)
540 | if path:
541 | morsel["path"] = path
542 | for k, v in kwargs.items():
543 | if k == 'max_age':
544 | k = 'max-age'
545 | morsel[k] = v
546 |
547 | def clear_cookie(self, name, path="/", domain=None):
548 | """Deletes the cookie with the given name."""
549 | expires = datetime.datetime.utcnow() - datetime.timedelta(days=365)
550 | self.set_cookie(name, value="", path=path, expires=expires,
551 | domain=domain)
552 |
553 | def clear_all_cookies(self):
554 | """Deletes all the cookies the user sent with this request."""
555 | for name in self.request.cookies:
556 | self.clear_cookie(name)
557 |
558 | def set_secure_cookie(self, name, value, expires_days=30, **kwargs):
559 | """Signs and timestamps a cookie so it cannot be forged."""
560 | self.set_cookie(name, self.create_signed_value(name, value),
561 | expires_days=expires_days, **kwargs)
562 |
563 | def create_signed_value(self, name, value):
564 | """Signs and timestamps a string so it cannot be forged.
565 | """
566 | return create_signed_value(COOKIE_SECRET, name, value)
567 |
568 | def send(self, start_response):
569 | status = "%d %s" % (self.status, HTTP_MAPPINGS.get(self.status))
570 | headers = ([('Content-Type', "%s; charset=utf-8" % self.content_type)] +
571 | [(k, v) for k, v in self.headers.iteritems()])
572 |
573 | if hasattr(self, "_new_cookie"):
574 | for cookie in self._new_cookie.values():
575 | headers.append(("Set-Cookie", utf8(cookie.OutputString(None))))
576 |
577 | start_response(status, headers)
578 |
579 | if isinstance(self.output, unicode):
580 | return self.output.encode('utf-8')
581 | else:
582 | return self.output
583 |
584 | def convert_to_ascii(self, data):
585 | if isinstance(data, unicode):
586 | try:
587 | return data.encode('us-ascii')
588 | except UnicodeError, e:
589 | raise
590 | else:
591 | return str(data)
592 |
593 |
594 | def handle_request(environ, start_response):
595 | """The main handler. Dispatches to the user's code."""
596 | try:
597 | request = Request(environ, start_response)
598 | except Exception, e:
599 | return handle_error(e)
600 |
601 | try:
602 | (re_url, url, callback), kwargs = find_matching_url(request)
603 | response = callback(request, **kwargs)
604 | except Exception, e:
605 | return handle_error(e, request)
606 |
607 | if not isinstance(response, Response):
608 | response = Response(response)
609 |
610 | return response.send(start_response)
611 |
612 |
613 | def handle_error(exception, request=None):
614 | """If an exception is thrown, deal with it and present an error page."""
615 | if request is None:
616 | request = {'_environ': {'PATH_INFO': ''}}
617 |
618 | if not getattr(exception, 'hide_traceback', False):
619 | (e_type, e_value, e_tb) = sys.exc_info()
620 | message = "%s occurred on '%s': %s\nTraceback: %s" % (
621 | exception.__class__,
622 | request._environ['PATH_INFO'],
623 | exception,
624 | ''.join(traceback.format_exception(e_type, e_value, e_tb))
625 | )
626 | request._environ['wsgi.errors'].write(message)
627 |
628 | if isinstance(exception, RequestError):
629 | status = getattr(exception, 'status', 404)
630 | else:
631 | status = 500
632 |
633 | if status in ERROR_HANDLERS:
634 | return ERROR_HANDLERS[status](request, exception)
635 |
636 | return not_found(request, exception)
637 |
638 |
639 | def find_matching_url(request):
640 | """Searches through the methods who've registed themselves with the HTTP decorators."""
641 | if not request.method in REQUEST_MAPPINGS:
642 | raise NotFound("The HTTP request method '%s' is not supported." % request.method)
643 |
644 | for url_set in REQUEST_MAPPINGS[request.method]:
645 | match = url_set[0].search(request.path)
646 |
647 | if match is not None:
648 | return (url_set, match.groupdict())
649 |
650 | raise NotFound("Sorry, nothing here.")
651 |
652 |
653 | def add_slash(url):
654 | """Adds a trailing slash for consistency in urls."""
655 | if not url.endswith('/'):
656 | url = url + '/'
657 | return url
658 |
659 |
660 | def content_type(filename):
661 | """
662 | Takes a guess at what the desired mime type might be for the requested file.
663 |
664 | Mostly only useful for static media files.
665 | """
666 | ct = 'text/plain'
667 | ct_guess = mimetypes.guess_type(filename)
668 |
669 | if ct_guess[0] is not None:
670 | ct = ct_guess[0]
671 |
672 | return ct
673 |
674 |
675 | def static_file(filename, root=MEDIA_ROOT):
676 | """
677 | Fetches a static file from the filesystem, relative to either the given
678 | MEDIA_ROOT or from the provided root directory.
679 | """
680 | if filename is None:
681 | raise Forbidden("You must specify a file you'd like to access.")
682 |
683 | # Strip the '/' from the beginning/end.
684 | valid_path = filename.strip('/')
685 |
686 | # Kill off any character trying to work their way up the filesystem.
687 | valid_path = valid_path.replace('//', '/').replace('/./', '/').replace('/../', '/')
688 |
689 | desired_path = os.path.join(root, valid_path)
690 |
691 | if not os.path.exists(desired_path):
692 | raise NotFound("File does not exist.")
693 |
694 | if not os.access(desired_path, os.R_OK):
695 | raise Forbidden("You do not have permission to access this file.")
696 |
697 | ct = str(content_type(desired_path))
698 |
699 | # Do the text types as a non-binary read.
700 | if ct.startswith('text') or ct.endswith('xml') or ct.endswith('json'):
701 | return open(desired_path, 'r').read()
702 |
703 | # Fall back to binary for everything else.
704 | return open(desired_path, 'rb').read()
705 |
706 |
707 | # Static file handler
708 |
709 | def serve_static_file(request, filename, root=MEDIA_ROOT, force_content_type=None):
710 | """
711 | Basic handler for serving up static media files.
712 |
713 | Accepts an optional ``root`` (filepath string, defaults to ``MEDIA_ROOT``) parameter.
714 | Accepts an optional ``force_content_type`` (string, guesses if ``None``) parameter.
715 | """
716 | file_contents = static_file(filename, root)
717 |
718 | if force_content_type is None:
719 | ct = content_type(filename)
720 | else:
721 | ct = force_content_type
722 |
723 | return Response(file_contents, content_type=ct)
724 |
725 |
726 | # Decorators
727 |
728 | def get(url):
729 | """Registers a method as capable of processing GET requests."""
730 | def wrapped(method):
731 | # Register.
732 | re_url = re.compile("^%s$" % add_slash(url))
733 | REQUEST_MAPPINGS['GET'].append((re_url, url, method))
734 | return method
735 | return wrapped
736 |
737 |
738 | def post(url):
739 | """Registers a method as capable of processing POST requests."""
740 | def wrapped(method):
741 | # Register.
742 | re_url = re.compile("^%s$" % add_slash(url))
743 | REQUEST_MAPPINGS['POST'].append((re_url, url, method))
744 | return method
745 | return wrapped
746 |
747 |
748 | def put(url):
749 | """Registers a method as capable of processing PUT requests."""
750 | def wrapped(method):
751 | # Register.
752 | re_url = re.compile("^%s$" % add_slash(url))
753 | REQUEST_MAPPINGS['PUT'].append((re_url, url, method))
754 | new.status = 201
755 | return method
756 | return wrapped
757 |
758 |
759 | def delete(url):
760 | """Registers a method as capable of processing DELETE requests."""
761 | def wrapped(method):
762 | # Register.
763 | re_url = re.compile("^%s$" % add_slash(url))
764 | REQUEST_MAPPINGS['DELETE'].append((re_url, url, method))
765 | return method
766 | return wrapped
767 |
768 |
769 | def error(code):
770 | """Registers a method for processing errors of a certain HTTP code."""
771 | def wrapped(method):
772 | # Register.
773 | ERROR_HANDLERS[code] = method
774 | return method
775 | return wrapped
776 |
777 |
778 | # Error handlers
779 |
780 | @error(403)
781 | def forbidden(request, exception):
782 | response = Response('Forbidden', status=403, content_type='text/plain')
783 | return response.send(request._start_response)
784 |
785 |
786 | @error(404)
787 | def not_found(request, exception):
788 | response = Response('Not Found', status=404, content_type='text/plain')
789 | return response.send(request._start_response)
790 |
791 |
792 | @error(500)
793 | def app_error(request, exception):
794 | response = Response('Application Error', status=500, content_type='text/plain')
795 | return response.send(request._start_response)
796 |
797 |
798 | @error(302)
799 | def redirect(request, exception):
800 | response = Response('', status=302, content_type='text/plain', headers=[('Location', exception.url)])
801 | return response.send(request._start_response)
802 |
803 |
804 | # Servers Adapters
805 |
806 | def wsgiref_adapter(host, port):
807 | from wsgiref.simple_server import make_server
808 | srv = make_server(host, port, handle_request)
809 | srv.serve_forever()
810 |
811 |
812 | def appengine_adapter(host, port):
813 | from google.appengine.ext.webapp import util
814 | util.run_wsgi_app(handle_request)
815 |
816 |
817 | def cherrypy_adapter(host, port):
818 | # Experimental (Untested).
819 | from cherrypy import wsgiserver
820 | server = wsgiserver.CherryPyWSGIServer((host, port), handle_request)
821 | server.start()
822 |
823 |
824 | def flup_adapter(host, port):
825 | # Experimental (Untested).
826 | from flup.server.fcgi import WSGIServer
827 | WSGIServer(handle_request, bindAddress=(host, port)).run()
828 |
829 |
830 | def paste_adapter(host, port):
831 | # Experimental (Untested).
832 | from paste import httpserver
833 | httpserver.serve(handle_request, host=host, port=str(port))
834 |
835 |
836 | def twisted_adapter(host, port):
837 | from twisted.web import server, wsgi
838 | from twisted.python.threadpool import ThreadPool
839 | from twisted.internet import reactor
840 |
841 | thread_pool = ThreadPool()
842 | thread_pool.start()
843 | reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop)
844 |
845 | ittyResource = wsgi.WSGIResource(reactor, thread_pool, handle_request)
846 | site = server.Site(ittyResource)
847 | reactor.listenTCP(port, site)
848 | reactor.run()
849 |
850 |
851 | def diesel_adapter(host, port):
852 | # Experimental (Mostly untested).
853 | from diesel.protocols.wsgi import WSGIApplication
854 | app = WSGIApplication(handle_request, port=int(port))
855 | app.run()
856 |
857 |
858 | def tornado_adapter(host, port):
859 | # Experimental (Mostly untested).
860 | from tornado.wsgi import WSGIContainer
861 | from tornado.httpserver import HTTPServer
862 | from tornado.ioloop import IOLoop
863 |
864 | container = WSGIContainer(handle_request)
865 | http_server = HTTPServer(container)
866 | http_server.listen(port)
867 | IOLoop.instance().start()
868 |
869 |
870 | def gunicorn_adapter(host, port):
871 | from gunicorn import version_info
872 |
873 | if version_info < (0, 9, 0):
874 | from gunicorn.arbiter import Arbiter
875 | from gunicorn.config import Config
876 | arbiter = Arbiter(Config({'bind': "%s:%d" % (host, int(port)), 'workers': 4}), handle_request)
877 | arbiter.run()
878 | else:
879 | from gunicorn.app.base import Application
880 |
881 | class IttyApplication(Application):
882 | def init(self, parser, opts, args):
883 | return {
884 | 'bind': '{0}:{1}'.format(host, port),
885 | 'workers': 4
886 | }
887 |
888 | def load(self):
889 | return handle_request
890 |
891 | IttyApplication().run()
892 |
893 |
894 | def gevent_adapter(host, port):
895 | from gevent import pywsgi
896 | pywsgi.WSGIServer((host, int(port)), handle_request).serve_forever()
897 |
898 |
899 | def eventlet_adapter(host, port):
900 | from eventlet import wsgi, listen
901 | wsgi.server(listen((host, int(port))), handle_request)
902 |
903 |
904 | WSGI_ADAPTERS = {
905 | 'wsgiref': wsgiref_adapter,
906 | 'appengine': appengine_adapter,
907 | 'cherrypy': cherrypy_adapter,
908 | 'flup': flup_adapter,
909 | 'paste': paste_adapter,
910 | 'twisted': twisted_adapter,
911 | 'diesel': diesel_adapter,
912 | 'tornado': tornado_adapter,
913 | 'gunicorn': gunicorn_adapter,
914 | 'gevent': gevent_adapter,
915 | 'eventlet': eventlet_adapter,
916 | }
917 |
918 |
919 | COOKIE_SECRET = None
920 |
921 | # Server
922 |
923 |
924 | def run_itty(server='wsgiref', host='localhost', port=8080, config=None,
925 | cookie_secret=None):
926 | """
927 | Runs the itty web server.
928 |
929 | Accepts an optional host (string), port (integer), server (string) and
930 | config (python module name/path as a string) parameters.
931 |
932 | By default, uses Python's built-in wsgiref implementation. Specify a server
933 | name from WSGI_ADAPTERS to use an alternate WSGI server.
934 | """
935 | if not server in WSGI_ADAPTERS:
936 | raise RuntimeError("Server '%s' is not a valid server. Please choose a different server." % server)
937 |
938 | if config is not None:
939 | # We'll let ImportErrors bubble up.
940 | config_options = __import__(config)
941 | host = getattr(config_options, 'host', host)
942 | port = getattr(config_options, 'port', port)
943 | server = getattr(config_options, 'server', server)
944 |
945 | # AppEngine seems to echo everything, even though it shouldn't. Accomodate.
946 | if server != 'appengine':
947 | print 'itty starting up (using %s)...' % server
948 | print 'Listening on http://%s:%s...' % (host, port)
949 | print 'Use Ctrl-C to quit.'
950 | print
951 |
952 | global COOKIE_SECRET
953 | COOKIE_SECRET = cookie_secret or base64.b64encode(os.urandom(32))
954 |
955 | try:
956 | WSGI_ADAPTERS[server](host, port)
957 | except KeyboardInterrupt:
958 | print 'Shutting down. Have a nice day!'
959 |
--------------------------------------------------------------------------------