├── .gitignore
├── app.py
├── bottle.py
├── bottle_session.py
├── domain.py
├── readme.markdown
├── settings.py
├── static
├── app.js
├── avatar-small.png
├── avatar.png
├── bg-clouds.png
├── retwis-py.css
└── retwis.png
├── tests.py
└── views
├── home_not_logged.tpl
├── login.tpl
├── mentions.tpl
├── shared
├── footer.tpl
├── form.tpl
├── header.tpl
├── nav.tpl
└── side.tpl
├── single.tpl
├── timeline.tpl
└── user.tpl
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *~
3 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import bottle
4 | import redis
5 | import settings
6 | #ugly hack
7 | settings.r = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)
8 |
9 | from bottle_session import Session
10 | from domain import User,Post,Timeline
11 |
12 | reserved_usernames = 'follow mentions home signup login logout post'
13 |
14 | def authenticate(handler):
15 | def _check_auth(*args,**kwargs):
16 | sess = Session(bottle.request,bottle.response)
17 | if not sess.is_new():
18 | user = User.find_by_id(sess['id'])
19 | if user:
20 | return handler(user,*args,**kwargs)
21 | bottle.redirect('/login')
22 | return _check_auth
23 |
24 | def logged_in_user():
25 | sess = Session(bottle.request,bottle.response)
26 | if not sess.is_new():
27 | return User.find_by_id(sess['id'])
28 | return None
29 |
30 | def user_is_logged():
31 | if logged_in_user():
32 | return True
33 | return False
34 |
35 | @bottle.route('/')
36 | def index():
37 | if user_is_logged():
38 | bottle.redirect('/home')
39 | return bottle.template('home_not_logged',logged=False)
40 |
41 | @bottle.route('/home')
42 | @authenticate
43 | def home(user):
44 | bottle.TEMPLATES.clear()
45 | counts = user.followees_count,user.followers_count,user.tweet_count
46 | if len(user.posts()) >0:
47 | last_tweet = user.posts()[0]
48 | else:
49 | last_tweet = None
50 | return bottle.template('timeline',timeline=user.timeline(),page='timeline',username=user.username,
51 | counts=counts,last_tweet=last_tweet,logged=True)
52 |
53 | @bottle.route('/mentions')
54 | @authenticate
55 | def mentions(user):
56 | counts = user.followees_count,user.followers_count,user.tweet_count
57 | return bottle.template('mentions',mentions=user.mentions(),page='mentions',username=user.username,
58 | counts=counts,posts=user.posts()[:1],logged=True)
59 |
60 | @bottle.route('/:name')
61 | def user_page(name):
62 | is_following,is_logged = False,user_is_logged()
63 | user = User.find_by_username(name)
64 | if user:
65 | counts = user.followees_count,user.followers_count,user.tweet_count
66 | logged_user = logged_in_user()
67 | himself = logged_user.username == name
68 | if logged_user:
69 | is_following = logged_user.following(user)
70 |
71 | return bottle.template('user',posts=user.posts(),counts=counts,page='user',
72 | username=user.username,logged=is_logged,is_following=is_following,himself=himself)
73 | else:
74 | return bottle.HTTPError(code=404)
75 |
76 | @bottle.route('/:name/statuses/:id')
77 | @bottle.validate(id=int)
78 | def status(name,id):
79 | post = Post.find_by_id(id)
80 | if post:
81 | if post.user.username == name:
82 | return bottle.template('single',username=post.user.username,tweet=post,page='single',
83 | logged=user_is_logged())
84 | return bottle.HTTPError(code=404,message='tweet not found')
85 |
86 | @bottle.route('/post',method='POST')
87 | @authenticate
88 | def post(user):
89 | content = bottle.request.POST['content']
90 | Post.create(user, content)
91 | bottle.redirect('/home')
92 |
93 | @bottle.route('/follow/:name',method='POST')
94 | @authenticate
95 | def post(user,name):
96 | user_to_follow = User.find_by_username(name)
97 | if user_to_follow:
98 | user.follow(user_to_follow)
99 | bottle.redirect('/%s' % name)
100 |
101 | @bottle.route('/unfollow/:name',method='POST')
102 | @authenticate
103 | def post(user,name):
104 | user_to_unfollow = User.find_by_username(name)
105 | if user_to_unfollow:
106 | user.stop_following(user_to_unfollow)
107 | bottle.redirect('/%s' % name)
108 |
109 |
110 | @bottle.route('/signup')
111 | @bottle.route('/login')
112 | def login():
113 | bottle.TEMPLATES.clear()
114 | if user_is_logged():
115 | bottle.redirect('/home')
116 | return bottle.template('login',page='login',error_login=False,error_signup=False,logged=False)
117 |
118 | @bottle.route('/login', method='POST')
119 | def login():
120 | if 'name' in bottle.request.POST and 'password' in bottle.request.POST:
121 | name = bottle.request.POST['name']
122 | password = bottle.request.POST['password']
123 |
124 | user = User.find_by_username(name)
125 | if user and user.password == settings.SALT + password:
126 | sess=Session(bottle.request,bottle.response)
127 | sess['id'] = user.id
128 | sess.save()
129 | bottle.redirect('/home')
130 |
131 | return bottle.template('login',page='login',error_login=True,error_signup=False,logged=False)
132 |
133 | @bottle.route('/logout')
134 | def logout():
135 | sess = Session(bottle.request,bottle.response)
136 | sess.invalidate()
137 | bottle.redirect('/')
138 |
139 |
140 | @bottle.route('/signup', method='POST')
141 | def sign_up():
142 | if 'name' in bottle.request.POST and 'password' in bottle.request.POST:
143 | name = bottle.request.POST['name']
144 | if name not in reserved_usernames.split():
145 | password = bottle.request.POST['password']
146 | user = User.create(name,password)
147 | if user:
148 | sess=Session(bottle.request,bottle.response)
149 | sess['id'] = user.id
150 | sess.save()
151 | bottle.redirect('/home')
152 | return bottle.template('login',page='login',error_login=False,error_signup=True,logged=False)
153 |
154 | @bottle.route('/static/:filename')
155 | def static_file(filename):
156 | bottle.send_file(filename, root='static/')
157 |
158 | bottle.run(host='localhost', port=8080,reloader=True)
159 |
--------------------------------------------------------------------------------
/bottle.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Bottle is a fast and simple micro-framework for small web applications. It
4 | offers request dispatching (Routes) with url parameter support, templates,
5 | a built-in HTTP Server and adapters for many third party WSGI/HTTP-server and
6 | template engines - all in a single file and with no dependencies other than the
7 | Python Standard Library.
8 |
9 | Homepage and documentation: http://wiki.github.com/defnull/bottle
10 |
11 | Licence (MIT)
12 | -------------
13 |
14 | Copyright (c) 2009, Marcel Hellkamp.
15 |
16 | Permission is hereby granted, free of charge, to any person obtaining a copy
17 | of this software and associated documentation files (the "Software"), to deal
18 | in the Software without restriction, including without limitation the rights
19 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20 | copies of the Software, and to permit persons to whom the Software is
21 | furnished to do so, subject to the following conditions:
22 |
23 | The above copyright notice and this permission notice shall be included in
24 | all copies or substantial portions of the Software.
25 |
26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
32 | THE SOFTWARE.
33 |
34 |
35 | Example
36 | -------
37 |
38 | This is an example::
39 |
40 | from bottle import route, run, request, response, send_file, abort
41 |
42 | @route('/')
43 | def hello_world():
44 | return 'Hello World!'
45 |
46 | @route('/hello/:name')
47 | def hello_name(name):
48 | return 'Hello %s!' % name
49 |
50 | @route('/hello', method='POST')
51 | def hello_post():
52 | name = request.POST['name']
53 | return 'Hello %s!' % name
54 |
55 | @route('/static/:filename#.*#')
56 | def static_file(filename):
57 | send_file(filename, root='/path/to/static/files/')
58 |
59 | run(host='localhost', port=8080)
60 | """
61 | from __future__ import with_statement
62 | __author__ = 'Marcel Hellkamp'
63 | __version__ = '0.7.0a'
64 | __license__ = 'MIT'
65 |
66 | import types
67 | import sys
68 | import cgi
69 | import mimetypes
70 | import os
71 | import os.path
72 | from traceback import format_exc
73 | import re
74 | import random
75 | import threading
76 | import time
77 | import warnings
78 | import email.utils
79 | from Cookie import SimpleCookie
80 | import subprocess
81 | import thread
82 | from tempfile import TemporaryFile
83 | import hmac
84 | import base64
85 | from urllib import quote as urlquote
86 | from urlparse import urlunsplit, urljoin
87 | import functools
88 | import inspect
89 |
90 | try:
91 | from collections import MutableMapping as DictMixin
92 | except ImportError: # pragma: no cover
93 | from UserDict import DictMixin
94 |
95 | if sys.version_info >= (3,0,0): # pragma: no cover
96 | # See Request.POST
97 | from io import BytesIO
98 | from io import TextIOWrapper
99 | else:
100 | from StringIO import StringIO as BytesIO
101 | TextIOWrapper = None
102 |
103 | try:
104 | from urlparse import parse_qs
105 | except ImportError: # pragma: no cover
106 | from cgi import parse_qs
107 |
108 | try:
109 | import cPickle as pickle
110 | except ImportError: # pragma: no cover
111 | import pickle
112 |
113 | try:
114 | try:
115 | from json import dumps as json_dumps
116 | except ImportError: # pragma: no cover
117 | from simplejson import dumps as json_dumps
118 | except ImportError: # pragma: no cover
119 | json_dumps = None
120 |
121 |
122 |
123 |
124 |
125 |
126 | # Exceptions and Events
127 |
128 | class BottleException(Exception):
129 | """ A base class for exceptions used by bottle. """
130 | pass
131 |
132 |
133 | class HTTPResponse(BottleException):
134 | """ Used to break execution and imediately finish the response """
135 | def __init__(self, output='', status=200, header=None):
136 | super(BottleException, self).__init__("HTTP Response %d" % status)
137 | self.status = int(status)
138 | self.output = output
139 | self.header = HeaderDict(header) if header else None
140 |
141 | def apply(self, response):
142 | if self.header:
143 | for key, value in self.header.iterallitems():
144 | response.header[key] = value
145 | response.status = self.status
146 |
147 |
148 | class HTTPError(HTTPResponse):
149 | """ Used to generate an error page """
150 | def __init__(self, code=500, message='Unknown Error', exception=None, header=None):
151 | super(HTTPError, self).__init__(message, code, header)
152 | self.exception = exception
153 |
154 | def __str__(self):
155 | return ERROR_PAGE_TEMPLATE % {
156 | 'status' : self.status,
157 | 'url' : str(request.path),
158 | 'error_name' : HTTP_CODES.get(self.status, 'Unknown').title(),
159 | 'error_message' : str(self.output)
160 | }
161 |
162 |
163 |
164 |
165 |
166 |
167 | # Routing
168 |
169 | class RouteError(BottleException):
170 | """ This is a base class for all routing related exceptions """
171 |
172 |
173 | class RouteSyntaxError(RouteError):
174 | """ The route parser found something not supported by this router """
175 |
176 |
177 | class RouteBuildError(RouteError):
178 | """ The route could not been build """
179 |
180 |
181 | class Route(object):
182 | ''' Represents a single route and can parse the dynamic route syntax '''
183 | syntax = re.compile(r'(.*?)(?%s)' % (data[1], data[0])
226 | elif token == 'ANON': out += '(?:%s)' % data
227 | return out
228 |
229 | def flat_re(self):
230 | ''' Return a regexp pattern with non-grouping parentheses '''
231 | return re.sub(r'\(\?P<[^>]*>|\((?!\?)', '(?:', self.group_re())
232 |
233 | def format_str(self):
234 | ''' Return a format string with named fields. '''
235 | if self.static:
236 | return self.route.replace('%','%%')
237 | out, i = '', 0
238 | for token, value in self.tokens():
239 | if token == 'TXT': out += value.replace('%','%%')
240 | elif token == 'ANON': out += '%%(anon%d)s' % i; i+=1
241 | elif token == 'VAR': out += '%%(%s)s' % value[1]
242 | return out
243 |
244 | @property
245 | def static(self):
246 | return not self.is_dynamic()
247 |
248 | def is_dynamic(self):
249 | ''' Return true if the route contains dynamic parts '''
250 | if not self._static:
251 | for token, value in self.tokens():
252 | if token != 'TXT':
253 | return True
254 | self._static = True
255 | return False
256 |
257 | def __repr__(self):
258 | return self.route
259 |
260 | def __eq__(self, other):
261 | return self.route == other.route\
262 | and self.static == other.static\
263 | and self.name == other.name\
264 | and self.target == other.target
265 |
266 |
267 | class Router(object):
268 | ''' A route associates a string (e.g. URL) with an object (e.g. function)
269 | Some dynamic routes may extract parts of the string and provide them as
270 | a dictionary. This router matches a string against multiple routes and
271 | returns the associated object along with the extracted data.
272 | '''
273 |
274 | def __init__(self):
275 | self.routes = [] # List of all installed routes
276 | self.static = dict() # Cache for static routes
277 | self.dynamic = [] # Cache structure for dynamic routes
278 | self.named = dict() # Cache for named routes and their format strings
279 |
280 | def add(self, *a, **ka):
281 | """ Adds a route->target pair or a Route object to the Router.
282 | See Route() for details.
283 | """
284 | route = a[0] if a and isinstance(a[0], Route) else Route(*a, **ka)
285 | self.routes.append(route)
286 | if route.name:
287 | self.named[route.name] = route.format_str()
288 | if route.static:
289 | self.static[route.route] = route.target
290 | return
291 | gpatt = route.group_re()
292 | fpatt = route.flat_re()
293 | try:
294 | gregexp = re.compile('^(%s)$' % gpatt) if '(?P' in gpatt else None
295 | combined = '%s|(^%s$)' % (self.dynamic[-1][0].pattern, fpatt)
296 | self.dynamic[-1] = (re.compile(combined), self.dynamic[-1][1])
297 | self.dynamic[-1][1].append((route.target, gregexp))
298 | except (AssertionError, IndexError), e: # AssertionError: Too many groups
299 | self.dynamic.append((re.compile('(^%s$)'%fpatt),[(route.target, gregexp)]))
300 | except re.error, e:
301 | raise RouteSyntaxError("Could not add Route: %s (%s)" % (route, e))
302 |
303 | def match(self, uri):
304 | ''' Matches an URL and returns a (handler, target) tuple '''
305 | if uri in self.static:
306 | return self.static[uri], {}
307 | for combined, subroutes in self.dynamic:
308 | match = combined.match(uri)
309 | if not match: continue
310 | target, groups = subroutes[match.lastindex - 1]
311 | groups = groups.match(uri).groupdict() if groups else {}
312 | return target, groups
313 | return None, {}
314 |
315 | def build(self, route_name, **args):
316 | ''' Builds an URL out of a named route and some parameters.'''
317 | try:
318 | return self.named[route_name] % args
319 | except KeyError:
320 | raise RouteBuildError("No route found with name '%s'." % route_name)
321 |
322 | def __eq__(self, other):
323 | return self.routes == other.routes
324 |
325 |
326 |
327 |
328 |
329 |
330 | # WSGI abstraction: Request and response management
331 |
332 | class Bottle(object):
333 | """ WSGI application """
334 |
335 | def __init__(self, catchall=True, autojson=True, path = ''):
336 | """ Create a new bottle instance.
337 | You usually don't have to do that. Use `bottle.app.push()` instead
338 | """
339 | self.routes = Router()
340 | self.default_route = None
341 | self.error_handler = {}
342 | self.jsondump = json_dumps if autojson and json_dumps else False
343 | self.catchall = catchall
344 | self.config = dict()
345 | self.serve = True
346 |
347 | def match_url(self, path, method='GET'):
348 | """ Find a callback bound to a path and a specific HTTP method.
349 | Return (callback, param) tuple or (None, {}).
350 | method: HEAD falls back to GET. HEAD and GET fall back to ALL.
351 | """
352 | path = path.strip().lstrip('/')
353 | handler, param = self.routes.match(method + ';' + path)
354 | if handler: return handler, param
355 | if method == 'HEAD':
356 | handler, param = self.routes.match('GET;' + path)
357 | if handler: return handler, param
358 | handler, param = self.routes.match('ANY;' + path)
359 | if handler: return handler, param
360 | return self.default_route, {}
361 |
362 | def get_url(self, routename, **kargs):
363 | """ Return a string that matches a named route """
364 | return '/' + self.routes.build(routename, **kargs).split(';', 1)[1]
365 |
366 | def route(self, path=None, method='GET', **kargs):
367 | """ Decorator: Bind a function to a GET request path.
368 |
369 | If the path parameter is None, the signature (name, args) of the
370 | decorated function is used to generate the path. See yieldroutes()
371 | for details.
372 |
373 | The method parameter (default: GET) specifies the HTTP request
374 | method to listen to.
375 | """
376 | method = method.upper()
377 | def wrapper(handler):
378 | paths = [] if path is None else [path.strip().lstrip('/')]
379 | if not paths: # Lets generate the path automatically
380 | paths = yieldroutes(handler)
381 | for p in paths:
382 | self.routes.add(method+';'+p, handler, **kargs)
383 | return handler
384 | return wrapper
385 |
386 | def default(self):
387 | """ Decorator: Add a default handler for undefined routes """
388 | def wrapper(handler):
389 | self.default_route = handler
390 | return handler
391 | return wrapper
392 |
393 | def error(self, code=500):
394 | """ Decorator: Registrer an output handler for a HTTP error code"""
395 | def wrapper(handler):
396 | self.error_handler[int(code)] = handler
397 | return handler
398 | return wrapper
399 |
400 | def handle(self, url, method, catchall=True):
401 | """ Handle a single request. Return handler output, HTTPResponse or
402 | HTTPError. If catchall is true, all exceptions thrown within a
403 | handler function are catched and returned as HTTPError(500).
404 | """
405 | if not self.serve:
406 | return HTTPError(503, "Server stopped")
407 |
408 | handler, args = self.match_url(request.path, request.method)
409 | if not handler:
410 | return HTTPError(404, "Not found")
411 |
412 | try:
413 | return handler(**args)
414 | except HTTPResponse, e:
415 | return e
416 | except (KeyboardInterrupt, SystemExit, MemoryError):
417 | raise
418 | except Exception, e:
419 | if not self.catchall:
420 | raise
421 | err = "Unhandled Exception: %s\n" % (repr(e))
422 | if DEBUG:
423 | err += '\n\nTraceback:\n' + format_exc(10)
424 | request.environ['wsgi.errors'].write(err)
425 | return HTTPError(500, err, e)
426 |
427 | def _cast(self, out):
428 | """ Try to cast the input into something WSGI compatible. Correct
429 | HTTP header and status codes when possible. Clear output on HEAD
430 | requests.
431 | Support: False, str, unicode, list(unicode), file, dict, list(dict),
432 | HTTPResponse and HTTPError
433 | """
434 | if isinstance(out, HTTPResponse):
435 | out.apply(response)
436 | if isinstance(out, HTTPError):
437 | out = self.error_handler.get(out.status, str)(out)
438 | else:
439 | out = out.output
440 | if not out:
441 | response.header['Content-Length'] = '0'
442 | return []
443 | if isinstance(out, types.StringType):
444 | out = [out]
445 | elif isinstance(out, unicode):
446 | out = [out.encode(response.charset)]
447 | elif isinstance(out, list) and isinstance(out[0], unicode):
448 | out = map(lambda x: x.encode(response.charset), out)
449 | elif hasattr(out, 'read'):
450 | out = request.environ.get('wsgi.file_wrapper',
451 | lambda x: iter(lambda: x.read(8192), ''))(out)
452 | elif self.jsondump and isinstance(out, dict)\
453 | or self.jsondump and isinstance(out, list) and isinstance(out[0], dict):
454 | out = [self.jsondump(out)]
455 | response.content_type = 'application/json'
456 | if isinstance(out, list) and len(out) == 1:
457 | response.header['Content-Length'] = str(len(out[0]))
458 | if response.status in (100, 101, 204, 304) or request.method == 'HEAD':
459 | out = [] # rfc2616 section 4.3
460 | if not hasattr(out, '__iter__'):
461 | raise TypeError('Request handler for route "%s" returned [%s] '
462 | 'which is not iterable.' % (request.path, type(out).__name__))
463 | return out
464 |
465 | def __call__(self, environ, start_response):
466 | """ The bottle WSGI-interface. """
467 | try:
468 | request.bind(environ, self)
469 | response.bind(self)
470 | out = self.handle(request.path, request.method)
471 | out = self._cast(out)
472 | status = '%d %s' % (response.status, HTTP_CODES[response.status])
473 | start_response(status, response.wsgiheader())
474 | return out
475 | except (KeyboardInterrupt, SystemExit, MemoryError):
476 | raise
477 | except Exception, e:
478 | if not self.catchall:
479 | raise
480 | err = '
Critial error while processing request: %s
' \
481 | % environ.get('PATH_INFO', '/')
482 | if DEBUG:
483 | err += 'Error:
\n%s
\n' % repr(e)
484 | err += 'Traceback:
\n%s
\n' % format_exc(10)
485 | environ['wsgi.errors'].write(err) #TODO: wsgi.error should not get html
486 | start_response('500 INTERNAL SERVER ERROR', [])
487 | return [err]
488 |
489 |
490 | class Request(threading.local, DictMixin):
491 | """ Represents a single HTTP request using thread-local attributes.
492 | The Resquest object wrapps a WSGI environment and can be used as such.
493 | """
494 | def __init__(self, environ=None, app=None):
495 | """ Create a new Request instance.
496 |
497 | You usually don't do this but use the global `bottle.request`
498 | instance instead.
499 | """
500 | self.bind(environ or {}, app)
501 |
502 | def bind(self, environ, app=None):
503 | """ Bind a new WSGI enviroment and clear out all previously computed
504 | attributes.
505 |
506 | This is done automatically for the global `bottle.request`
507 | instance on every request.
508 | """
509 | if isinstance(environ, Request): # Recycle already parsed content
510 | for key in self.__dict__: #TODO: Test this
511 | setattr(self, key, getattr(environ, key))
512 | self.app = app
513 | return
514 | self._GET = self._POST = self._GETPOST = self._COOKIES = None
515 | self._body = self._header = None
516 | self.environ = environ
517 | self.app = app
518 | # These attributes are used anyway, so it is ok to compute them here
519 | self.path = environ.get('PATH_INFO', '/')
520 | if not self.path.startswith('/'):
521 | self.path = '/' + self.path
522 | self.method = environ.get('REQUEST_METHOD', 'GET').upper()
523 |
524 | def __getitem__(self, key):
525 | """ Shortcut for Request.environ.__getitem__ """
526 | return self.environ[key]
527 |
528 | def __setitem__(self, key, value):
529 | """ Shortcut for Request.environ.__setitem__ """
530 | self.environ[key] = value
531 |
532 | def keys(self):
533 | """ Shortcut for Request.environ.keys() """
534 | return self.environ.keys()
535 |
536 | @property
537 | def query_string(self):
538 | """ The content of the QUERY_STRING environment variable. """
539 | return self.environ.get('QUERY_STRING', '')
540 |
541 | @property
542 | def fullpath(self):
543 | """ Request path including SCRIPT_NAME (if present) """
544 | return self.environ.get('SCRIPT_NAME', '').rstrip('/') + self.path
545 |
546 | @property
547 | def url(self):
548 | """ Full URL as requested by the client (computed).
549 |
550 | This value is constructed out of different environment variables
551 | and includes scheme, host, port, scriptname, path and query string.
552 | """
553 | scheme = self.environ.get('wsgi.url_scheme', 'http')
554 | host = self.environ.get('HTTP_HOST', None)
555 | if not host:
556 | host = self.environ.get('SERVER_NAME')
557 | port = self.environ.get('SERVER_PORT', '80')
558 | if scheme + port not in ('https443', 'http80'):
559 | host += ':' + port
560 | parts = (scheme, host, urlquote(self.fullpath), self.query_string, '')
561 | return urlunsplit(parts)
562 |
563 | @property
564 | def content_length(self):
565 | """ Content-Length header as an integer, -1 if not specified """
566 | return int(self.environ.get('CONTENT_LENGTH','') or -1)
567 |
568 | @property
569 | def header(self):
570 | ''' :class:`HeaderDict` filled with request headers.
571 |
572 | HeaderDict keys are case insensitive str.title()d
573 | '''
574 | if self._header is None:
575 | self._header = HeaderDict()
576 | for key, value in self.environ.iteritems():
577 | if key.startswith('HTTP_'):
578 | key = key[5:].replace('_','-').title()
579 | self._header[key] = value
580 | return self._header
581 |
582 | @property
583 | def GET(self):
584 | """ The QUERY_STRING parsed into a MultiDict.
585 |
586 | Keys and values are strings. Multiple values per key are possible.
587 | See MultiDict for details.
588 | """
589 | if self._GET is None:
590 | data = parse_qs(self.query_string, keep_blank_values=True)
591 | self._GET = MultiDict()
592 | for key, values in data.iteritems():
593 | for value in values:
594 | self._GET[key] = value
595 | return self._GET
596 |
597 | @property
598 | def POST(self):
599 | """ The HTTP POST body parsed into a MultiDict.
600 |
601 | This supports urlencoded and multipart POST requests. Multipart
602 | is commonly used for file uploads and may result in some of the
603 | values beeing cgi.FieldStorage objects instead of strings.
604 |
605 | Multiple values per key are possible. See MultiDict for details.
606 | """
607 | if self._POST is None:
608 | save_env = dict() # Build a save environment for cgi
609 | for key in ('REQUEST_METHOD', 'CONTENT_TYPE', 'CONTENT_LENGTH'):
610 | if key in self.environ:
611 | save_env[key] = self.environ[key]
612 | save_env['QUERY_STRING'] = '' # Without this, sys.argv is called!
613 | if TextIOWrapper:
614 | fb = TextIOWrapper(self.body, encoding='ISO-8859-1')
615 | else:
616 | fb = self.body
617 | data = cgi.FieldStorage(fp=fb, environ=save_env)
618 | self._POST = MultiDict()
619 | for item in data.list:
620 | self._POST[item.name] = item if item.filename else item.value
621 | return self._POST
622 |
623 | @property
624 | def params(self):
625 | """ A combined MultiDict with POST and GET parameters. """
626 | if self._GETPOST is None:
627 | self._GETPOST = MultiDict(self.GET)
628 | self._GETPOST.update(dict(self.POST))
629 | return self._GETPOST
630 |
631 | @property
632 | def body(self):
633 | """ The HTTP request body as a seekable buffer object.
634 |
635 | This property returns a copy of the `wsgi.input` stream and should
636 | be used instead of `environ['wsgi.input']`.
637 | """
638 | if self._body is None:
639 | maxread = max(0, self.content_length)
640 | stream = self.environ['wsgi.input']
641 | self._body = BytesIO() if maxread < MEMFILE_MAX else TemporaryFile(mode='w+b')
642 | while maxread > 0:
643 | part = stream.read(min(maxread, MEMFILE_MAX))
644 | if not part: #TODO: Wrong content_length. Error? Do nothing?
645 | break
646 | self._body.write(part)
647 | maxread -= len(part)
648 | self.environ['wsgi.input'] = self._body
649 | self._body.seek(0)
650 | return self._body
651 |
652 | @property
653 | def auth(self): #TODO: Tests and docs. Add support for digest. namedtuple?
654 | """ HTTP authorisation data as a (user, passwd) tuple. (experimental)
655 |
656 | This implementation currently only supports basic auth and returns
657 | None on errors.
658 | """
659 | return parse_auth(self.environ.get('HTTP_AUTHORIZATION'))
660 |
661 | @property
662 | def COOKIES(self):
663 | """ Cookie information parsed into a dictionary.
664 |
665 | Secure cookies are NOT decoded automatically. See
666 | Request.get_cookie() for details.
667 | """
668 | if self._COOKIES is None:
669 | raw_dict = SimpleCookie(self.environ.get('HTTP_COOKIE',''))
670 | self._COOKIES = {}
671 | for cookie in raw_dict.itervalues():
672 | self._COOKIES[cookie.key] = cookie.value
673 | return self._COOKIES
674 |
675 | def get_cookie(self, *args):
676 | """ Return the (decoded) value of a cookie. """
677 | value = self.COOKIES.get(*args)
678 | sec = self.app.config['securecookie.key']
679 | dec = cookie_decode(value, sec)
680 | return dec or value
681 |
682 |
683 | class Response(threading.local):
684 | """ Represents a single HTTP response using thread-local attributes.
685 | """
686 |
687 | def bind(self, app):
688 | """ Resets the Response object to its factory defaults. """
689 | self._COOKIES = None
690 | self.status = 200
691 | self.header = HeaderDict()
692 | self.content_type = 'text/html; charset=UTF-8'
693 | self.error = None
694 | self.app = app
695 |
696 | def wsgiheader(self):
697 | ''' Returns a wsgi conform list of header/value pairs. '''
698 | for c in self.COOKIES.values():
699 | if c.OutputString() not in self.header.getall('Set-Cookie'):
700 | self.header.append('Set-Cookie', c.OutputString())
701 | return list(self.header.iterallitems())
702 |
703 | @property
704 | def charset(self):
705 | """ Return the charset specified tin the content-type header.
706 |
707 | This defaults to `UTF-8`.
708 | """
709 | if 'charset=' in self.content_type:
710 | return self.content_type.split('charset=')[-1].split(';')[0].strip()
711 | return 'UTF-8'
712 |
713 | @property
714 | def COOKIES(self):
715 | """ A dict-like SimpleCookie instance. Use Response.set_cookie() instead. """
716 | if not self._COOKIES:
717 | self._COOKIES = SimpleCookie()
718 | return self._COOKIES
719 |
720 | def set_cookie(self, key, value, **kargs):
721 | """ Add a new cookie with various options.
722 |
723 | If the cookie value is not a string, a secure cookie is created.
724 |
725 | Possible options are:
726 | expires, path, comment, domain, max_age, secure, version, httponly
727 | See http://de.wikipedia.org/wiki/HTTP-Cookie#Aufbau for details
728 | """
729 | if not isinstance(value, basestring):
730 | sec = self.app.config['securecookie.key']
731 | value = cookie_encode(value, sec).decode('ascii') #2to3 hack
732 | self.COOKIES[key] = value
733 | for k, v in kargs.iteritems():
734 | self.COOKIES[key][k.replace('_', '-')] = v
735 |
736 | def get_content_type(self):
737 | """ Current 'Content-Type' header. """
738 | return self.header['Content-Type']
739 |
740 | def set_content_type(self, value):
741 | self.header['Content-Type'] = value
742 |
743 | content_type = property(get_content_type, set_content_type, None,
744 | get_content_type.__doc__)
745 |
746 |
747 |
748 |
749 |
750 |
751 | # Data Structures
752 |
753 | class BaseController(object):
754 | _singleton = None
755 | def __new__(cls, *a, **k):
756 | if not cls._singleton:
757 | cls._singleton = object.__new__(cls, *a, **k)
758 | return cls._singleton
759 |
760 |
761 | class MultiDict(DictMixin):
762 | """ A dict that remembers old values for each key """
763 | # collections.MutableMapping would be better for Python >= 2.6
764 | def __init__(self, *a, **k):
765 | self.dict = dict()
766 | for k, v in dict(*a, **k).iteritems():
767 | self[k] = v
768 |
769 | def __len__(self): return len(self.dict)
770 | def __iter__(self): return iter(self.dict)
771 | def __contains__(self, key): return key in self.dict
772 | def __delitem__(self, key): del self.dict[key]
773 | def keys(self): return self.dict.keys()
774 | def __getitem__(self, key): return self.get(key, KeyError, -1)
775 | def __setitem__(self, key, value): self.append(key, value)
776 |
777 | def append(self, key, value): self.dict.setdefault(key, []).append(value)
778 | def replace(self, key, value): self.dict[key] = [value]
779 | def getall(self, key): return self.dict.get(key) or []
780 |
781 | def get(self, key, default=None, index=-1):
782 | if key not in self.dict and default != KeyError:
783 | return [default][index]
784 | return self.dict[key][index]
785 |
786 | def iterallitems(self):
787 | for key, values in self.dict.iteritems():
788 | for value in values:
789 | yield key, value
790 |
791 |
792 | class HeaderDict(MultiDict):
793 | """ Same as :class:`MultiDict`, but title()s the keys and overwrites by default. """
794 | def __contains__(self, key): return MultiDict.__contains__(self, key.title())
795 | def __getitem__(self, key): return MultiDict.__getitem__(self, key.title())
796 | def __delitem__(self, key): return MultiDict.__delitem__(self, key.title())
797 | def __setitem__(self, key, value): self.replace(key, value)
798 | def append(self, key, value): return MultiDict.append(self, key.title(), str(value))
799 | def replace(self, key, value): return MultiDict.replace(self, key.title(), str(value))
800 | def getall(self, key): return MultiDict.getall(self, key.title())
801 |
802 | class AppStack(list):
803 | """ A stack implementation. """
804 |
805 | def __call__(self):
806 | """ Return the current default app. """
807 | return self[-1]
808 |
809 | def push(self, value=None):
810 | """ Add a new Bottle instance to the stack """
811 | if not isinstance(value, Bottle):
812 | value = Bottle()
813 | self.append(value)
814 | return value
815 |
816 |
817 |
818 |
819 | # Module level functions
820 |
821 | # BC: 0.6.4 and needed for run()
822 | app = default_app = AppStack([Bottle()])
823 |
824 |
825 | def abort(code=500, text='Unknown Error: Appliction stopped.'):
826 | """ Aborts execution and causes a HTTP error. """
827 | raise HTTPError(code, text)
828 |
829 |
830 | def redirect(url, code=303):
831 | """ Aborts execution and causes a 303 redirect """
832 | scriptname = request.environ.get('SCRIPT_NAME', '').rstrip('/') + '/'
833 | location = urljoin(request.url, urljoin(scriptname, url))
834 | raise HTTPResponse("", status=code, header=dict(Location=location))
835 |
836 |
837 | def send_file(*a, **k): #BC 0.6.4
838 | """ Raises the output of static_file() """
839 | raise static_file(*a, **k)
840 |
841 |
842 | def static_file(filename, root, guessmime=True, mimetype=None, download=False):
843 | """ Opens a file in a save way and returns a HTTPError object with status
844 | code 200, 305, 401 or 404. Sets Content-Type, Content-Length and
845 | Last-Modified header. Obeys If-Modified-Since header and HEAD requests.
846 | """
847 | root = os.path.abspath(root) + os.sep
848 | filename = os.path.abspath(os.path.join(root, filename.strip('/\\')))
849 | header = dict()
850 |
851 | if not filename.startswith(root):
852 | return HTTPError(401, "Access denied.")
853 | if not os.path.exists(filename) or not os.path.isfile(filename):
854 | return HTTPError(404, "File does not exist.")
855 | if not os.access(filename, os.R_OK):
856 | return HTTPError(401, "You do not have permission to access this file.")
857 |
858 | if not mimetype and guessmime:
859 | header['Content-Type'] = mimetypes.guess_type(filename)[0]
860 | else:
861 | header['Content-Type'] = mimetype if mimetype else 'text/plain'
862 |
863 | if download == True:
864 | download = os.path.basename(filename)
865 | if download:
866 | header['Content-Disposition'] = 'attachment; filename=%s' % download
867 |
868 | stats = os.stat(filename)
869 | lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime))
870 | header['Last-Modified'] = lm
871 | ims = request.environ.get('HTTP_IF_MODIFIED_SINCE')
872 | if ims:
873 | ims = ims.split(";")[0].strip() # IE sends "; length=146"
874 | ims = parse_date(ims)
875 | if ims is not None and ims >= stats.st_mtime:
876 | return HTTPResponse("Not modified", status=304, header=header)
877 | header['Content-Length'] = stats.st_size
878 | if request.method == 'HEAD':
879 | return HTTPResponse('', header=header)
880 | else:
881 | return HTTPResponse(open(filename, 'rb'), header=header)
882 |
883 |
884 |
885 |
886 |
887 |
888 | # Utilities
889 |
890 | def url(routename, **kargs):
891 | """ Return a named route filled with arguments """
892 | return app().get_url(routename, **kargs)
893 |
894 |
895 | def parse_date(ims):
896 | """ Parses rfc1123, rfc850 and asctime timestamps and returns UTC epoch. """
897 | try:
898 | ts = email.utils.parsedate_tz(ims)
899 | return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone
900 | except (TypeError, ValueError, IndexError):
901 | return None
902 |
903 |
904 | def parse_auth(header):
905 | try:
906 | method, data = header.split(None, 1)
907 | if method.lower() == 'basic':
908 | name, pwd = base64.b64decode(data).split(':', 1)
909 | return name, pwd
910 | except (KeyError, ValueError, TypeError), a:
911 | return None
912 |
913 |
914 | def cookie_encode(data, key):
915 | ''' Encode and sign a pickle-able object. Return a string '''
916 | msg = base64.b64encode(pickle.dumps(data, -1))
917 | sig = base64.b64encode(hmac.new(key, msg).digest())
918 | return u'!'.encode('ascii') + sig + u'?'.encode('ascii') + msg #2to3 hack
919 |
920 |
921 | def cookie_decode(data, key):
922 | ''' Verify and decode an encoded string. Return an object or None'''
923 | if isinstance(data, unicode): data = data.encode('ascii') #2to3 hack
924 | if cookie_is_encoded(data):
925 | sig, msg = data.split(u'?'.encode('ascii'),1) #2to3 hack
926 | if sig[1:] == base64.b64encode(hmac.new(key, msg).digest()):
927 | return pickle.loads(base64.b64decode(msg))
928 | return None
929 |
930 |
931 | def cookie_is_encoded(data):
932 | ''' Verify and decode an encoded string. Return an object or None'''
933 | return bool(data.startswith(u'!'.encode('ascii')) and u'?'.encode('ascii') in data) #2to3 hack
934 |
935 |
936 | def yieldroutes(func):
937 | """ Return a generator for routes that match the signature (name, args)
938 | of the func parameter. This may yield more than one route if the function
939 | takes optional keyword arguments. The output is best described by example:
940 | a() -> '/a'
941 | b(x, y) -> '/b/:x/:y'
942 | c(x, y=5) -> '/c/:x' and '/c/:x/:y'
943 | d(x=5, y=6) -> '/d' and '/d/:x' and '/d/:x/:y'
944 | """
945 | path = func.__name__.replace('__','/').lstrip('/')
946 | spec = inspect.getargspec(func)
947 | argc = len(spec[0]) - len(spec[3] or [])
948 | path += ('/:%s' * argc) % tuple(spec[0][:argc])
949 | yield path
950 | for arg in spec[0][argc:]:
951 | path += '/:%s' % arg
952 | yield path
953 |
954 |
955 |
956 | # Decorators
957 | #TODO: Replace default_app() with app()
958 |
959 | def validate(**vkargs):
960 | """
961 | Validates and manipulates keyword arguments by user defined callables.
962 | Handles ValueError and missing arguments by raising HTTPError(403).
963 | """
964 | def decorator(func):
965 | def wrapper(**kargs):
966 | for key, value in vkargs.iteritems():
967 | if key not in kargs:
968 | abort(403, 'Missing parameter: %s' % key)
969 | try:
970 | kargs[key] = value(kargs[key])
971 | except ValueError, e:
972 | abort(403, 'Wrong parameter format for: %s' % key)
973 | return func(**kargs)
974 | return wrapper
975 | return decorator
976 |
977 |
978 | def route(*a, **ka):
979 | """ Decorator: Bind a route to a callback.
980 | The method parameter (default: GET) specifies the HTTP request
981 | method to listen to """
982 | return app().route(*a, **ka)
983 |
984 | get = functools.partial(route, method='GET')
985 | get.__doc__ = route.__doc__
986 |
987 | post = functools.partial(route, method='POST')
988 | post.__doc__ = route.__doc__.replace('GET','POST')
989 |
990 | put = functools.partial(route, method='PUT')
991 | put.__doc__ = route.__doc__.replace('GET','PUT')
992 |
993 | delete = functools.partial(route, method='DELETE')
994 | delete.__doc__ = route.__doc__.replace('GET','DELETE')
995 |
996 | def default():
997 | """
998 | Decorator for request handler. Same as app().default(handler).
999 | """
1000 | return app().default()
1001 |
1002 |
1003 | def error(code=500):
1004 | """
1005 | Decorator for error handler. Same as app().error(code, handler).
1006 | """
1007 | return app().error(code)
1008 |
1009 |
1010 |
1011 |
1012 |
1013 |
1014 | # Server adapter
1015 |
1016 | class ServerAdapter(object):
1017 | def __init__(self, host='127.0.0.1', port=8080, **kargs):
1018 | self.options = kargs
1019 | self.host = host
1020 | self.port = int(port)
1021 |
1022 | def run(self, handler): # pragma: no cover
1023 | pass
1024 |
1025 | def __repr__(self):
1026 | args = ', '.join(['%s=%s'%(k,repr(v)) for k, v in self.options.items()])
1027 | return "%s(%s)" % (self.__class__.__name__, args)
1028 |
1029 |
1030 | class CGIServer(ServerAdapter):
1031 | def run(self, handler): # pragma: no cover
1032 | from wsgiref.handlers import CGIHandler
1033 | CGIHandler().run(handler) # Just ignore host and port here
1034 |
1035 |
1036 | class FlupFCGIServer(ServerAdapter):
1037 | def run(self, handler): # pragma: no cover
1038 | import flup.server.fcgi
1039 | flup.server.fcgi.WSGIServer(handler, bindAddress=(self.host, self.port)).run()
1040 |
1041 |
1042 | class WSGIRefServer(ServerAdapter):
1043 | def run(self, handler): # pragma: no cover
1044 | from wsgiref.simple_server import make_server
1045 | srv = make_server(self.host, self.port, handler)
1046 | srv.serve_forever()
1047 |
1048 |
1049 | class CherryPyServer(ServerAdapter):
1050 | def run(self, handler): # pragma: no cover
1051 | from cherrypy import wsgiserver
1052 | server = wsgiserver.CherryPyWSGIServer((self.host, self.port), handler)
1053 | server.start()
1054 |
1055 |
1056 | class PasteServer(ServerAdapter):
1057 | def run(self, handler): # pragma: no cover
1058 | from paste import httpserver
1059 | from paste.translogger import TransLogger
1060 | app = TransLogger(handler)
1061 | httpserver.serve(app, host=self.host, port=str(self.port), **self.options)
1062 |
1063 |
1064 | class FapwsServer(ServerAdapter):
1065 | """
1066 | Extremly fast webserver using libev.
1067 | See http://william-os4y.livejournal.com/
1068 | """
1069 | def run(self, handler): # pragma: no cover
1070 | import fapws._evwsgi as evwsgi
1071 | from fapws import base
1072 | evwsgi.start(self.host, self.port)
1073 | evwsgi.set_base_module(base)
1074 | def app(environ, start_response):
1075 | environ['wsgi.multiprocess'] = False
1076 | return handler(environ, start_response)
1077 | evwsgi.wsgi_cb(('',app))
1078 | evwsgi.run()
1079 |
1080 |
1081 | class TornadoServer(ServerAdapter):
1082 | """ Untested. As described here:
1083 | http://github.com/facebook/tornado/blob/master/tornado/wsgi.py#L187 """
1084 | def run(self, handler): # pragma: no cover
1085 | import tornado.wsgi
1086 | import tornado.httpserver
1087 | import tornado.ioloop
1088 | container = tornado.wsgi.WSGIContainer(handler)
1089 | server = tornado.httpserver.HTTPServer(container)
1090 | server.listen(port=self.port)
1091 | tornado.ioloop.IOLoop.instance().start()
1092 |
1093 |
1094 | class AppEngineServer(ServerAdapter):
1095 | """ Untested. """
1096 | def run(self, handler):
1097 | from google.appengine.ext.webapp import util
1098 | util.run_wsgi_app(handler)
1099 |
1100 |
1101 | class TwistedServer(ServerAdapter):
1102 | """ Untested. """
1103 | def run(self, handler):
1104 | import twisted.web.wsgi
1105 | import twisted.internet
1106 | resource = twisted.web.wsgi.WSGIResource(twisted.internet.reactor,
1107 | twisted.internet.reactor.getThreadPool(), handler)
1108 | site = server.Site(resource)
1109 | twisted.internet.reactor.listenTCP(self.port, se.fhost)
1110 | twisted.internet.reactor.run()
1111 |
1112 |
1113 | class DieselServer(ServerAdapter):
1114 | """ Untested. """
1115 | def run(self, handler):
1116 | from diesel.protocols.wsgi import WSGIApplication
1117 | app = WSGIApplication(handler, port=self.port)
1118 | app.run()
1119 |
1120 |
1121 | class GunicornServer(ServerAdapter):
1122 | """ Untested. """
1123 | def run(self, handler):
1124 | import gunicorn.arbiter
1125 | gunicorn.arbiter.Arbiter((self.host, self.port), 4, handler).run()
1126 |
1127 |
1128 | class AutoServer(ServerAdapter):
1129 | """ Untested. """
1130 | adapters = [FapwsServer, TornadoServer, CherryPyServer, PasteServer,
1131 | TwistedServer, GunicornServer, WSGIRefServer]
1132 | def run(self, handler):
1133 | for sa in adapters:
1134 | try:
1135 | return sa(self.host, self.port, **self.options).run()
1136 | except ImportError:
1137 | pass
1138 |
1139 |
1140 | def run(app=None, server=WSGIRefServer, host='127.0.0.1', port=8080,
1141 | interval=1, reloader=False, **kargs):
1142 | """ Runs bottle as a web server. """
1143 | app = app if app else default_app()
1144 | quiet = bool(kargs.get('quiet', False))
1145 | # Instantiate server, if it is a class instead of an instance
1146 | if isinstance(server, type):
1147 | server = server(host=host, port=port, **kargs)
1148 | if not isinstance(server, ServerAdapter):
1149 | raise RuntimeError("Server must be a subclass of WSGIAdapter")
1150 | if not quiet and isinstance(server, ServerAdapter): # pragma: no cover
1151 | if not reloader or os.environ.get('BOTTLE_CHILD') == 'true':
1152 | print "Bottle server starting up (using %s)..." % repr(server)
1153 | print "Listening on http://%s:%d/" % (server.host, server.port)
1154 | print "Use Ctrl-C to quit."
1155 | print
1156 | else:
1157 | print "Bottle auto reloader starting up..."
1158 | try:
1159 | if reloader and interval:
1160 | reloader_run(server, app, interval)
1161 | else:
1162 | server.run(app)
1163 | except KeyboardInterrupt:
1164 | if not quiet: # pragma: no cover
1165 | print "Shutting Down..."
1166 |
1167 |
1168 | #TODO: If the parent process is killed (with SIGTERM) the childs survive...
1169 | def reloader_run(server, app, interval):
1170 | if os.environ.get('BOTTLE_CHILD') == 'true':
1171 | # We are a child process
1172 | files = dict()
1173 | for module in sys.modules.values():
1174 | file_path = getattr(module, '__file__', None)
1175 | if file_path and os.path.isfile(file_path):
1176 | file_split = os.path.splitext(file_path)
1177 | if file_split[1] in ('.py', '.pyc', '.pyo'):
1178 | file_path = file_split[0] + '.py'
1179 | files[file_path] = os.stat(file_path).st_mtime
1180 | thread.start_new_thread(server.run, (app,))
1181 | while True:
1182 | time.sleep(interval)
1183 | for file_path, file_mtime in files.iteritems():
1184 | if not os.path.exists(file_path):
1185 | print "File changed: %s (deleted)" % file_path
1186 | elif os.stat(file_path).st_mtime > file_mtime:
1187 | print "File changed: %s (modified)" % file_path
1188 | else: continue
1189 | print "Restarting..."
1190 | app.serve = False
1191 | time.sleep(interval) # be nice and wait for running requests
1192 | sys.exit(3)
1193 | while True:
1194 | args = [sys.executable] + sys.argv
1195 | environ = os.environ.copy()
1196 | environ['BOTTLE_CHILD'] = 'true'
1197 | exit_status = subprocess.call(args, env=environ)
1198 | if exit_status != 3:
1199 | sys.exit(exit_status)
1200 |
1201 |
1202 |
1203 |
1204 |
1205 |
1206 | # Templates
1207 |
1208 | class TemplateError(HTTPError):
1209 | def __init__(self, message):
1210 | HTTPError.__init__(self, 500, message)
1211 |
1212 |
1213 | class BaseTemplate(object):
1214 | """ Base class and minimal API for template adapters """
1215 | extentions = ['tpl','html','thtml','stpl']
1216 |
1217 | def __init__(self, source=None, name=None, lookup=[], encoding='utf8'):
1218 | """ Create a new template.
1219 | If the source parameter (str or buffer) is missing, the name argument
1220 | is used to guess a template filename. Subclasses can assume that
1221 | either self.source or self.filename is set. Both are strings.
1222 | The lookup-argument works similar to sys.path for templates.
1223 | The encoding parameter is used to decode byte strings or files.
1224 | """
1225 | self.name = name
1226 | self.source = source.read() if hasattr(source, 'read') else source
1227 | self.filename = None
1228 | self.lookup = map(os.path.abspath, lookup)
1229 | self.encoding = encoding
1230 | if not self.source and self.name:
1231 | self.filename = self.search(self.name, self.lookup)
1232 | if not self.filename:
1233 | raise TemplateError('Template %s not found.' % repr(name))
1234 | if not self.source and not self.filename:
1235 | raise TemplateError('No template specified.')
1236 | self.prepare()
1237 |
1238 | @classmethod
1239 | def search(cls, name, lookup=[]):
1240 | """ Search name in all directiries specified in lookup.
1241 | First without, then with common extentions. Return first hit. """
1242 | if os.path.isfile(name): return name
1243 | for spath in lookup:
1244 | fname = os.path.join(spath, name)
1245 | if os.path.isfile(fname):
1246 | return fname
1247 | for ext in cls.extentions:
1248 | if os.path.isfile('%s.%s' % (fname, ext)):
1249 | return '%s.%s' % (fname, ext)
1250 |
1251 | def prepare(self):
1252 | """ Run preparatios (parsing, caching, ...).
1253 | It should be possible to call this again to refresh a template.
1254 | """
1255 | raise NotImplementedError
1256 |
1257 | def render(self, **args):
1258 | """ Render the template with the specified local variables and return
1259 | a single byte or unicode string. If it is a byte string, the encoding
1260 | must match self.encoding. This method must be thread save!
1261 | """
1262 | raise NotImplementedError
1263 |
1264 |
1265 | class MakoTemplate(BaseTemplate):
1266 | default_filters=None
1267 | global_variables={}
1268 |
1269 | def prepare(self):
1270 | from mako.template import Template
1271 | from mako.lookup import TemplateLookup
1272 | #TODO: This is a hack... http://github.com/defnull/bottle/issues#issue/8
1273 | options = dict(input_encoding=self.encoding, default_filters=MakoTemplate.default_filters)
1274 | mylookup = TemplateLookup(directories=['.']+self.lookup, **options)
1275 | if self.source:
1276 | self.tpl = Template(self.source, lookup=mylookup)
1277 | else: #mako cannot guess extentions. We can, but only at top level...
1278 | name = self.name
1279 | if not os.path.splitext(name)[1]:
1280 | name += os.path.splitext(self.filename)[1]
1281 | self.tpl = mylookup.get_template(name)
1282 |
1283 | def render(self, **args):
1284 | _defaults = MakoTemplate.global_variables.copy()
1285 | _defaults.update(args)
1286 | return self.tpl.render(**_defaults)
1287 |
1288 |
1289 | class CheetahTemplate(BaseTemplate):
1290 | def prepare(self):
1291 | from Cheetah.Template import Template
1292 | self.context = threading.local()
1293 | self.context.vars = {}
1294 | if self.source:
1295 | self.tpl = Template(source=self.source, searchList=[self.context.vars])
1296 | else:
1297 | self.tpl = Template(file=self.filename, searchList=[self.context.vars])
1298 |
1299 | def render(self, **args):
1300 | self.context.vars.update(args)
1301 | out = str(self.tpl)
1302 | self.context.vars.clear()
1303 | return [out]
1304 |
1305 |
1306 | class Jinja2Template(BaseTemplate):
1307 | env = None # hopefully, a Jinja environment is actually thread-safe
1308 |
1309 | def prepare(self):
1310 | if not self.env:
1311 | from jinja2 import Environment, FunctionLoader
1312 | self.env = Environment(line_statement_prefix="#", loader=FunctionLoader(self.loader))
1313 | if self.source:
1314 | self.tpl = self.env.from_string(self.source)
1315 | else:
1316 | self.tpl = self.env.get_template(self.filename)
1317 |
1318 | def render(self, **args):
1319 | return self.tpl.render(**args).encode("utf-8")
1320 |
1321 | def loader(self, name):
1322 | fname = self.search(name, self.lookup)
1323 | if fname:
1324 | with open(fname) as f:
1325 | return f.read().decode(self.encoding)
1326 |
1327 |
1328 | class SimpleTemplate(BaseTemplate):
1329 | re_python = re.compile(r'^\s*%\s*(?:(if|elif|else|try|except|finally|for|'
1330 | 'while|with|def|class)|(include|rebase)|(end)|(.*))')
1331 | re_inline = re.compile(r'\{\{(.*?)\}\}')
1332 | dedent_keywords = ('elif', 'else', 'except', 'finally')
1333 |
1334 | def prepare(self):
1335 | if self.source:
1336 | code = self.translate(self.source)
1337 | self.co = compile(code, '', 'exec')
1338 | else:
1339 | code = self.translate(open(self.filename).read())
1340 | self.co = compile(code, self.filename, 'exec')
1341 |
1342 | def translate(self, template):
1343 | indent = 0
1344 | strbuffer = []
1345 | code = []
1346 | self.includes = dict()
1347 | class PyStmt(str):
1348 | def __repr__(self): return 'str(' + self + ')'
1349 | def flush(allow_nobreak=False):
1350 | if len(strbuffer):
1351 | if allow_nobreak and strbuffer[-1].endswith("\\\\\n"):
1352 | strbuffer[-1]=strbuffer[-1][:-3]
1353 | code.append(' ' * indent + "_stdout.append(%s)" % repr(''.join(strbuffer)))
1354 | code.append((' ' * indent + '\n') * len(strbuffer)) # to preserve line numbers
1355 | del strbuffer[:]
1356 | def cadd(line): code.append(" " * indent + line.strip() + '\n')
1357 | for line in template.splitlines(True):
1358 | m = self.re_python.match(line)
1359 | if m:
1360 | flush(allow_nobreak=True)
1361 | keyword, subtpl, end, statement = m.groups()
1362 | if keyword:
1363 | if keyword in self.dedent_keywords:
1364 | indent -= 1
1365 | cadd(line[m.start(1):])
1366 | indent += 1
1367 | elif subtpl:
1368 | tmp = line[m.end(2):].strip().split(None, 1)
1369 | if not tmp:
1370 | cadd("_stdout.extend(_base)")
1371 | else:
1372 | name = tmp[0]
1373 | args = tmp[1:] and tmp[1] or ''
1374 | if name not in self.includes:
1375 | self.includes[name] = SimpleTemplate(name=name, lookup=self.lookup)
1376 | if subtpl == 'include':
1377 | cadd("_ = _includes[%s].execute(_stdout, %s)"
1378 | % (repr(name), args))
1379 | else:
1380 | cadd("_tpl['_rebase'] = (_includes[%s], dict(%s))"
1381 | % (repr(name), args))
1382 | elif end:
1383 | indent -= 1
1384 | cadd('#' + line[m.start(3):])
1385 | elif statement:
1386 | cadd(line[m.start(4):])
1387 | else:
1388 | splits = self.re_inline.split(line) # text, (expr, text)*
1389 | if len(splits) == 1:
1390 | strbuffer.append(line)
1391 | else:
1392 | flush()
1393 | for i in range(1, len(splits), 2):
1394 | splits[i] = PyStmt(splits[i])
1395 | splits = [x for x in splits if bool(x)]
1396 | cadd("_stdout.extend(%s)" % repr(splits))
1397 | flush()
1398 | return ''.join(code)
1399 |
1400 | def execute(self, stdout, **args):
1401 | args['_stdout'] = stdout
1402 | args['_includes'] = self.includes
1403 | args['_tpl'] = args
1404 | eval(self.co, args)
1405 | if '_rebase' in args:
1406 | subtpl, args = args['_rebase']
1407 | args['_base'] = stdout[:] #copy stdout
1408 | del stdout[:] # clear stdout
1409 | return subtpl.execute(stdout, **args)
1410 | return args
1411 |
1412 | def render(self, **args):
1413 | """ Render the template using keyword arguments as local variables. """
1414 | stdout = []
1415 | self.execute(stdout, **args)
1416 | return stdout
1417 |
1418 |
1419 | def template(tpl, template_adapter=SimpleTemplate, **args):
1420 | '''
1421 | Get a rendered template as a string iterator.
1422 | You can use a name, a filename or a template string as first parameter.
1423 | '''
1424 | lookup = args.get('template_lookup', TEMPLATE_PATH)
1425 | if tpl not in TEMPLATES or DEBUG:
1426 | if "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
1427 | TEMPLATES[tpl] = template_adapter(source=tpl, lookup=lookup)
1428 | else:
1429 | TEMPLATES[tpl] = template_adapter(name=tpl, lookup=lookup)
1430 | if not TEMPLATES[tpl]:
1431 | abort(500, 'Template (%s) not found' % tpl)
1432 | args['abort'] = abort
1433 | args['request'] = request
1434 | args['response'] = response
1435 | return TEMPLATES[tpl].render(**args)
1436 |
1437 | mako_template = functools.partial(template, template_adapter=MakoTemplate)
1438 | cheetah_template = functools.partial(template, template_adapter=CheetahTemplate)
1439 | jinja2_template = functools.partial(template, template_adapter=Jinja2Template)
1440 |
1441 | def view(tpl_name, **defaults):
1442 | ''' Decorator: Rendes a template for a handler.
1443 | Return a dict of template vars to fill out the template.
1444 | '''
1445 | def decorator(func):
1446 | @functools.wraps(func)
1447 | def wrapper(*args, **kargs):
1448 | tplvars = dict(defaults)
1449 | tplvars.update(func(*args, **kargs))
1450 | return template(tpl_name, **tplvars)
1451 | return wrapper
1452 | return decorator
1453 |
1454 | mako_view = functools.partial(view, template_adapter=MakoTemplate)
1455 | cheetah_view = functools.partial(view, template_adapter=CheetahTemplate)
1456 | jinja2_view = functools.partial(view, template_adapter=Jinja2Template)
1457 |
1458 |
1459 |
1460 |
1461 |
1462 |
1463 | # Modul initialization and configuration
1464 |
1465 | TEMPLATE_PATH = ['./', './views/']
1466 | TEMPLATES = {}
1467 | DEBUG = False
1468 | MEMFILE_MAX = 1024*100
1469 | HTTP_CODES = {
1470 | 100: 'CONTINUE',
1471 | 101: 'SWITCHING PROTOCOLS',
1472 | 200: 'OK',
1473 | 201: 'CREATED',
1474 | 202: 'ACCEPTED',
1475 | 203: 'NON-AUTHORITATIVE INFORMATION',
1476 | 204: 'NO CONTENT',
1477 | 205: 'RESET CONTENT',
1478 | 206: 'PARTIAL CONTENT',
1479 | 300: 'MULTIPLE CHOICES',
1480 | 301: 'MOVED PERMANENTLY',
1481 | 302: 'FOUND',
1482 | 303: 'SEE OTHER',
1483 | 304: 'NOT MODIFIED',
1484 | 305: 'USE PROXY',
1485 | 306: 'RESERVED',
1486 | 307: 'TEMPORARY REDIRECT',
1487 | 400: 'BAD REQUEST',
1488 | 401: 'UNAUTHORIZED',
1489 | 402: 'PAYMENT REQUIRED',
1490 | 403: 'FORBIDDEN',
1491 | 404: 'NOT FOUND',
1492 | 405: 'METHOD NOT ALLOWED',
1493 | 406: 'NOT ACCEPTABLE',
1494 | 407: 'PROXY AUTHENTICATION REQUIRED',
1495 | 408: 'REQUEST TIMEOUT',
1496 | 409: 'CONFLICT',
1497 | 410: 'GONE',
1498 | 411: 'LENGTH REQUIRED',
1499 | 412: 'PRECONDITION FAILED',
1500 | 413: 'REQUEST ENTITY TOO LARGE',
1501 | 414: 'REQUEST-URI TOO LONG',
1502 | 415: 'UNSUPPORTED MEDIA TYPE',
1503 | 416: 'REQUESTED RANGE NOT SATISFIABLE',
1504 | 417: 'EXPECTATION FAILED',
1505 | 500: 'INTERNAL SERVER ERROR',
1506 | 501: 'NOT IMPLEMENTED',
1507 | 502: 'BAD GATEWAY',
1508 | 503: 'SERVICE UNAVAILABLE',
1509 | 504: 'GATEWAY TIMEOUT',
1510 | 505: 'HTTP VERSION NOT SUPPORTED',
1511 | }
1512 | """ A dict of known HTTP error and status codes """
1513 |
1514 |
1515 | ERROR_PAGE_TEMPLATE = """
1516 |
1517 |
1518 | Error %(status)d: %(error_name)s
1519 |
1520 |
1521 | Error %(status)d: %(error_name)s
1522 | Sorry, the requested URL %(url)s caused an error:
1523 |
1524 | %(error_message)s
1525 |
1526 |
1527 |
1528 | """
1529 | """ The HTML template used for error messages """
1530 |
1531 | request = Request()
1532 | """ Whenever a page is requested, the :class:`Bottle` WSGI handler stores
1533 | metadata about the current request into this instance of :class:`Request`.
1534 | It is thread-save and can be accessed from within handler functions. """
1535 |
1536 | response = Response()
1537 | """ The :class:`Bottle` WSGI handler uses metasata assigned to this instance
1538 | of :class:`Response` to generate the WSGI response. """
1539 |
1540 | local = threading.local()
1541 |
1542 | #TODO: Global and app local configuration (debug, defaults, ...) is a mess
1543 |
1544 | def debug(mode=True):
1545 | """ Change the debug level.
1546 | There is only one debug level supported at the moment."""
1547 | global DEBUG
1548 | DEBUG = bool(mode)
--------------------------------------------------------------------------------
/bottle_session.py:
--------------------------------------------------------------------------------
1 | # bottle_session.py - based on :
2 | # gmemsess.py - memcache-backed session Class for Google Appengine
3 | # Version 1.2
4 | # Copyright 2008 Greg Fawcett
5 |
6 | #substituting memcache for redis
7 | # Version 0.1
8 | # Copyright 2010 Tim Bart
9 |
10 | #
11 | # This program is free software: you can redistribute it and/or modify
12 | # it under the terms of the GNU General Public License as published by
13 | # the Free Software Foundation, either version 3 of the License, or
14 | # (at your option) any later version.
15 | #
16 | # This program is distributed in the hope that it will be useful,
17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 | # GNU General Public License for more details.
20 | #
21 | # You should have received a copy of the GNU General Public License
22 | # along with this program. If not, see .
23 |
24 | import random
25 | import pickle
26 | import settings
27 |
28 | r = settings.r
29 |
30 | _sidChars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
31 | _defaultTimeout=30*60 # 30 min
32 | _defaultCookieName='gsid'
33 |
34 | #----------------------------------------------------------------------
35 | class Session(dict):
36 | """A secure lightweight memcache-backed session Class for Google Appengine."""
37 |
38 | #----------------------------------------------------------
39 | def __init__(self,request,response,name=_defaultCookieName,timeout=_defaultTimeout):
40 | """Create a session object.
41 |
42 | Keyword arguments:
43 | rh -- the parent's request handler (usually self)
44 | name -- the cookie name (defaults to "gsid")
45 | timeout -- the number of seconds the session will last between
46 | requests (defaults to 1800 secs - 30 minutes)
47 | """
48 | self.request=request
49 | self.response = response
50 | self._timeout=timeout
51 | self._name=name
52 | self._new=True
53 | self._invalid=False
54 | dict.__init__(self)
55 |
56 | _name = request.COOKIES.get(self._name, None)
57 | if _name:
58 | self._sid= _name
59 | data = r.get(self._sid)
60 | if data:
61 | self.update(pickle.loads(data))
62 | # memcache timeout is absolute, so we need to reset it on each access
63 | r.set(self._sid,data)
64 | r.expire(self._name,self._timeout)
65 | self._new=False
66 | return
67 |
68 | # Create a new session ID
69 | # There are about 10^14 combinations, so guessing won't work
70 | self._sid=random.choice(_sidChars)+random.choice(_sidChars)+\
71 | random.choice(_sidChars)+random.choice(_sidChars)+\
72 | random.choice(_sidChars)+random.choice(_sidChars)+\
73 | random.choice(_sidChars)+random.choice(_sidChars)
74 | self.response.set_cookie(self._name,self._sid, path='/')
75 |
76 | #----------------------------------------------------------
77 | def save(self):
78 | """Save session data."""
79 | if not self._invalid:
80 | r.set(self._sid,pickle.dumps(self.copy()))
81 | r.expire(self._name,self._timeout)
82 |
83 | #----------------------------------------------------------
84 | def is_new(self):
85 | """Returns True if session was created during this request."""
86 | return self._new
87 |
88 | #----------------------------------------------------------
89 | def invalidate(self):
90 | """Delete session data and cookie."""
91 | self.response.set_cookie(self._name,'',expires=-100)
92 | r.delete(self._sid)
93 | self.clear()
94 | self._invalid=True
95 |
--------------------------------------------------------------------------------
/domain.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import redis
3 | import re
4 | import settings
5 |
6 | r = settings.r
7 |
8 | class Timeline:
9 | def page(self,page):
10 | _from = (page-1)*10
11 | _to = (page)*10
12 | return [Post(post_id) for post_ids in r.lrange('timeline',_from,_to)]
13 |
14 | class Model(object):
15 | def __init__(self,id):
16 | self.__dict__['id'] = id
17 |
18 | def __eq__(self,other):
19 | return self.id == other.id
20 |
21 | def __setattr__(self,name,value):
22 | if name not in self.__dict__:
23 | klass = self.__class__.__name__.lower()
24 | key = '%s:id:%s:%s' % (klass,self.id,name.lower())
25 | r.set(key,value)
26 | else:
27 | self.__dict__[name] = value
28 |
29 | def __getattr__(self,name):
30 | if name not in self.__dict__:
31 | klass = self.__class__.__name__.lower()
32 | v = r.get('%s:id:%s:%s' % (klass,self.id,name.lower()))
33 | if v:
34 | return v
35 | raise AttributeError('%s doesn\'t exist' % name)
36 | else:
37 | self.__dict__[name] = value
38 |
39 | class User(Model):
40 | @staticmethod
41 | def find_by_username(username):
42 | _id = r.get("user:username:%s" % username)
43 | if _id is not None:
44 | return User(int(_id))
45 | else:
46 | return None
47 |
48 | @staticmethod
49 | def find_by_id(_id):
50 | if r.exists("user:id:%s:username" % _id):
51 | return User(int(_id))
52 | else:
53 | return None
54 |
55 | @staticmethod
56 | def create(username, password):
57 | user_id = r.incr("user:uid")
58 | if not r.get("user:username:%s" % username):
59 | r.set("user:id:%s:username" % user_id, username)
60 | r.set("user:username:%s" % username, user_id)
61 |
62 | #fake salting obviously :)
63 | salt = settings.SALT
64 | r.set("user:id:%s:password" % user_id, salt+password)
65 | r.lpush("users", user_id)
66 | return User(user_id)
67 | return None
68 |
69 | def posts(self,page=1):
70 | _from, _to = (page-1)*10, page*10
71 | posts = r.lrange("user:id:%s:posts" % self.id, _from, _to)
72 | if posts:
73 | return [Post(int(post_id)) for post_id in posts]
74 | return []
75 |
76 | def timeline(self,page=1):
77 | _from, _to = (page-1)*10, page*10
78 | timeline= r.lrange("user:id:%s:timeline" % self.id, _from, _to)
79 | if timeline:
80 | return [Post(int(post_id)) for post_id in timeline]
81 | return []
82 |
83 | def mentions(self,page=1):
84 | _from, _to = (page-1)*10, page*10
85 | mentions = r.lrange("user:id:%s:mentions" % self.id, _from, _to)
86 | if mentions:
87 | return [Post(int(post_id)) for post_id in mentions]
88 | return []
89 |
90 |
91 | def add_post(self,post):
92 | r.lpush("user:id:%s:posts" % self.id, post.id)
93 | r.lpush("user:id:%s:timeline" % self.id, post.id)
94 | r.sadd('posts:id', post.id)
95 |
96 | def add_timeline_post(self,post):
97 | r.lpush("user:id:%s:timeline" % self.id, post.id)
98 |
99 | def add_mention(self,post):
100 | r.lpush("user:id:%s:mentions" % self.id, post.id)
101 |
102 | def follow(self,user):
103 | if user == self:
104 | return
105 | else:
106 | r.sadd("user:id:%s:followees" % self.id, user.id)
107 | user.add_follower(self)
108 |
109 | def stop_following(self,user):
110 | r.srem("user:id:%s:followees" % self.id, user.id)
111 | user.remove_follower(self)
112 |
113 | def following(self,user):
114 | if r.sismember("user:id:%s:followees" % self.id, user.id):
115 | return True
116 | return False
117 |
118 | @property
119 | def followers(self):
120 | followers = r.smembers("user:id:%s:followers" % self.id)
121 | if followers:
122 | return [User(int(user_id)) for user_id in followers]
123 | return []
124 |
125 | @property
126 | def followees(self):
127 | followees = r.smembers("user:id:%s:followees" % self.id)
128 | if followees:
129 | return [User(int(user_id)) for user_id in followees]
130 | return []
131 |
132 |
133 | #added
134 | @property
135 | def tweet_count(self):
136 | return r.llen("user:id:%s:posts" % self.id) or 0
137 |
138 | @property
139 | def followees_count(self):
140 | return r.scard("user:id:%s:followees" % self.id) or 0
141 |
142 | @property
143 | def followers_count(self):
144 | return r.scard("user:id:%s:followers" % self.id) or 0
145 |
146 | def add_follower(self,user):
147 | r.sadd("user:id:%s:followers" % self.id, user.id)
148 |
149 | def remove_follower(self,user):
150 | r.srem("user:id:%s:followers" % self.id, user.id)
151 |
152 | class Post(Model):
153 | @staticmethod
154 | def create(user, content):
155 | post_id = r.incr("post:uid")
156 | post = Post(post_id)
157 | post.content = content
158 | post.user_id = user.id
159 | #post.created_at = Time.now.to_s
160 | user.add_post(post)
161 | r.lpush("timeline", post_id)
162 | for follower in user.followers:
163 | follower.add_timeline_post(post)
164 |
165 | mentions = re.findall('@\w+', content)
166 | for mention in mentions:
167 | u = User.find_by_username(mention[1:])
168 | if u:
169 | u.add_mention(post)
170 |
171 | @staticmethod
172 | def find_by_id(id):
173 | if r.sismember('posts:id', int(id)):
174 | return Post(id)
175 | return None
176 |
177 | @property
178 | def user(self):
179 | return User.find_by_id(r.get("post:id:%s:user_id" % self.id))
180 |
181 |
182 | def main():
183 | pass
184 |
185 | if __name__ == '__main__':
186 | main()
187 |
--------------------------------------------------------------------------------
/readme.markdown:
--------------------------------------------------------------------------------
1 | retwis-py
2 | =========
3 |
4 | What is it ?
5 | ------------
6 |
7 | **retwis-py** is a *Twitter clone* based on [Redis](http://code.google.com/p/redis/) and the [bottle.py framework](http://bottle.paws.de/). It is a *direct* python port of the [ruby version of Retwis](http://github.com/danlucraft/retwis-rb) by Daniel Lucraft.
8 |
9 | Tim Bart [http://twitter.com/pims](http://twitter.com/pims)
10 |
11 | Requirements
12 | ------------
13 |
14 | * Python
15 | * Redis: [http://code.google.com/p/redis/](http://code.google.com/p/redis/)
16 |
17 | Starting Application
18 | --------------------
19 |
20 | Make sure the redis server is running.
21 |
22 | Run:
23 | python tests.py
24 | python app.py
25 |
26 | License
27 | -------
28 | The MIT License
29 |
30 | Copyright (c) 2010 Tim Bart
31 |
32 | Permission is hereby granted, free of charge, to any person obtaining a copy
33 | of this software and associated documentation files (the "Software"), to deal
34 | in the Software without restriction, including without limitation the rights
35 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
36 | copies of the Software, and to permit persons to whom the Software is
37 | furnished to do so, subject to the following conditions:
38 |
39 | The above copyright notice and this permission notice shall be included in
40 | all copies or substantial portions of the Software.
41 |
42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
44 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
45 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
46 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
47 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
48 | THE SOFTWARE.
49 |
--------------------------------------------------------------------------------
/settings.py:
--------------------------------------------------------------------------------
1 | #/usr/bin/env python
2 | REDIS_DB = 0
3 | REDIS_PORT = 6379
4 | REDIS_HOST = 'localhost'
5 |
6 | SALT = 'retwis'
7 |
8 | #serves as "hub" for dynamic settings of redis
9 | #ugly hack, needs to be fixed in next update
10 | r = None
--------------------------------------------------------------------------------
/static/app.js:
--------------------------------------------------------------------------------
1 | /* playing nice with other kids */
2 | (function(){
3 |
4 | /* bye bye old browsers */
5 | if(!document.getElementsByClassName){return;}
6 |
7 | /* Look Ma', no jQuery! */
8 | var $ = function(id,scope){ s = scope||document;return s.getElementById(id);}
9 | var $$ = function(tag,scope){s = scope||document;return s.getElementsByTagName(tag);}
10 | var $$$ = function(cls,scope){s = scope||document;return s.getElementsByClassName(cls);}
11 |
12 | var regexp = {
13 | url :/((https?\:\/\/)|(www\.))(\S+)(\w{2,4})(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/gi,
14 | twitterUsername : /(@)(\w+)/g
15 | }
16 |
17 | var enhance = function()
18 | {
19 | //summary : replace url in text by url + link
20 | //dependencies : $$, $$$
21 | //params : null
22 | //returns : void
23 |
24 |
25 | var linkify = function(text){
26 |
27 | //Sometimes people have a problem, and decide to solve it with regular expressions. Now they have two problems. – Jamie Zawinski
28 | //Thx ricky : http://rickyrosario.com/blog/converting-a-url-into-a-link-in-javascript-linkify-function
29 |
30 | //summary : replace url in text by url + link
31 | //dependencies : regexp
32 | //params :
33 | // (string) text
34 | //returns : (string) text
35 |
36 | if (text) {
37 | text = text.replace(regexp.url,
38 | function(url){
39 | var full_url = url;
40 | if (!full_url.match('^https?:\/\/')) {
41 | full_url = 'http://' + full_url;
42 | }
43 | return '' + url + '';
44 | });
45 | }
46 | return text;
47 | }
48 |
49 | var twitterify = function(text)
50 | {
51 | if(text){
52 | text = text.replace(regexp.twitterUsername,
53 | function(username){
54 | short_username = username.substring(1,username.length)
55 | return ""+ username+"";
56 | });
57 | return text;
58 | }
59 | }
60 |
61 | var results = $$$('tweets');
62 | var i = results.length;
63 | while(i--){
64 | //var n = results[i].getElementsByTagName('p')[0];
65 | var n = results[i];
66 | var p = $$('p',results[i])[0];
67 | if (n == null){break;} //defensive programming FTW !
68 | p.innerHTML = linkify(p.innerHTML);
69 | n.innerHTML = twitterify(n.innerHTML);
70 | }
71 |
72 |
73 |
74 | }
75 |
76 | enhance();
77 | }());
--------------------------------------------------------------------------------
/static/avatar-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pims/retwis-py/6a75cde753b4d2288fa4e3e4c1399b582d194920/static/avatar-small.png
--------------------------------------------------------------------------------
/static/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pims/retwis-py/6a75cde753b4d2288fa4e3e4c1399b582d194920/static/avatar.png
--------------------------------------------------------------------------------
/static/bg-clouds.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pims/retwis-py/6a75cde753b4d2288fa4e3e4c1399b582d194920/static/bg-clouds.png
--------------------------------------------------------------------------------
/static/retwis-py.css:
--------------------------------------------------------------------------------
1 | /* v1.0 | 20080212 */
2 | /* http://meyerweb.com/eric/tools/css/reset/ */
3 |
4 | html, body, div, span, applet, object, iframe,
5 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
6 | a, abbr, acronym, address, big, cite, code,
7 | del, dfn, em, font, img, ins, kbd, q, s, samp,
8 | small, strike, strong, sub, sup, tt, var,
9 | b, u, i, center,
10 | dl, dt, dd, ol, ul, li,
11 | fieldset, form, label, legend,
12 | table, caption, tbody, tfoot, thead, tr, th, td {
13 | margin: 0;
14 | padding: 0;
15 | border: 0;
16 | outline: 0;
17 | font-size: 100%;
18 | vertical-align: baseline;
19 | background: transparent;
20 | }
21 | body {
22 | line-height: 1;
23 | }
24 | ol, ul {
25 | list-style: none;
26 | }
27 | blockquote, q {
28 | quotes: none;
29 | }
30 | blockquote:before, blockquote:after,
31 | q:before, q:after {
32 | content: '';
33 | content: none;
34 | }
35 |
36 | /* remember to define focus styles! */
37 | :focus {
38 | outline: 0;
39 | }
40 |
41 | /* remember to highlight inserts somehow! */
42 | ins {
43 | text-decoration: none;
44 | }
45 | del {
46 | text-decoration: line-through;
47 | }
48 |
49 | /* tables still need 'cellspacing="0"' in the markup */
50 | table {
51 | border-collapse: collapse;
52 | border-spacing: 0;
53 | }
54 |
55 | body{font-size:62.5%;font-family:'Helvetica','Arial';background:url('/static/bg-clouds.png') top left repeat-x rgb(192,222,237);}
56 | p{font-size:1.2em;}
57 | #header{width:760px;margin:0 auto;overflow:hidden;padding:5px 0 0 0;}
58 | #nav{float:right;width:200px;background:#FFF;-webkit-border-radius:5px;line-height:1.3em;font-size:1.3em;padding:5px;}
59 | a{color:#16387C;text-decoration:none;}
60 | a:hover{text-decoration:underline;}
61 | #container{width:760px;margin:20px auto;background:rgba(255,255,255,1);-webkit-border-radius:5px;}
62 | .timeline #container,.mentions #container{overflow:hidden}
63 | #main,#side{float:left;min-height:500px;}
64 | #main{width:540px;padding:20px 10px 0 10px;background:rgba(255,255,255,1)}
65 | #side{width:199px;background:rgb(221,238,250);border-left:1px solid rgb(155,179,203);-webkit-border-top-right-radius:5px}
66 | .timeline form{width:100%;}
67 | textarea{width:98%;height:40px;border:1px solid #eee;}
68 | .tweets p{border-bottom:1px solid #eee;overflow:hidden;margin:0 0 10px 0;font-size:1.4em;color:#151515;}
69 | .tweets p img{float:left;width:48px;height:48px;margin:0 5px 5px 0;}
70 | .tweets p strong a{color: #16387C;text-decoration:none;}
71 | .tweets p span{display:block;color:#d4d4d4;font-size:0.8em;}
72 | .tweets p span a {color:#d4d4d4;text-decoration:none;}
73 | fieldset{border:0;overflow:hidden;width:100%;padding:2px 0 15px 0;}
74 | fieldset p{float:left;width:80%;color:#d4d4d4;}
75 | fieldset input{float:right;width:18%;-webkit-border-radius:5px;border:1px solid #d4d4d4;background:#efefef; color:#888;font-size:1.4em;margin:0 5px 0 0;}
76 | fieldset input:focus{border:1px solid #888;color:#555;background:#d4d4d4;}
77 | h1{font-size:1.6em;padding: 0 0 5px 0;font-weight:normal;}
78 | p.bio img{float:left;margin:0 5px 5px 5px;}
79 | p.bio {font-size:1.4em;padding:5px 0;}
80 | p.bio span a {display:block;font-size:0.8em;text-decoration:none;}
81 | p.bio span a:hover{text-decoration:underline;}
82 | ul.follow li{font-size:1.2em;}
83 | ul.follow li a{display:block;padding:5px;text-decoration:none;clear:right;color: #16387C;}
84 | ul.follow li a:hover{background:rgba(255,255,255,0.5);}
85 | ul.follow li a span{float:right;font-weight:bold;}
86 |
87 | #login,#signup{margin:0 auto;width:250px;padding:20px;background:#FFF;float:left;}
88 |
89 | .single .tweets p {background:#FFF;-webkit-border-radius:5px;padding:10px;}
90 | .follow-user,.unfollow-user{background:#eee;padding:5px;margin:0 0 10px 0;-webkit-border-radius:5px;text-align:center;}
--------------------------------------------------------------------------------
/static/retwis.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pims/retwis-py/6a75cde753b4d2288fa4e3e4c1399b582d194920/static/retwis.png
--------------------------------------------------------------------------------
/tests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # encoding: utf-8
3 | """
4 | tests.py
5 |
6 | Created by tim bart on 2010-02-25.
7 | Copyright (c) 2010 Pims. All rights reserved.
8 | """
9 |
10 | import unittest
11 | import redis
12 | import settings
13 | #make sure it's different from 'production' settings since db will be flushed
14 | settings.r = redis.Redis(host='localhost', port=6379, db=9)
15 | from domain import User,Post
16 |
17 | class tests(unittest.TestCase):
18 | def setUp(self):
19 | self.r = settings.r
20 | self.r.flushdb()
21 | self.params = dict(username='pims',password='password',post='hello world')
22 |
23 | def tearDown(self):
24 | self.r.flushdb()
25 |
26 | def test_create_user(self):
27 | user = User.create(self.params['username'],self.params['password'])
28 | self.assertEqual(self.params['username'], user.username)
29 | self.assertEqual(settings.SALT + self.params['password'], user.password)
30 | self.assertEqual(1,user.id)
31 | self.assertEqual(0,len(user.followees))
32 | self.assertEqual(0,len(user.followers))
33 | self.assertEqual(0,len(user.posts()))
34 | self.assertEqual(0,len(user.mentions()))
35 | self.assertEqual(0,len(user.timeline()))
36 |
37 | user = User.create(self.params['username'],self.params['password'])
38 | self.assertEqual(None,user)
39 |
40 | def test_follow(self):
41 | user_to_follow = User.create('anonymous','password')
42 | me = User.create(self.params['username'],self.params['password'])
43 | me.follow(user_to_follow)
44 | self.assertEqual(1,len(me.followees))
45 | self.assertEqual(1,len(user_to_follow.followers))
46 | self.assertEqual(0,len(me.followers))
47 | self.assertEqual(0,len(user_to_follow.followees))
48 |
49 | self.assertEqual(True,me.following(user_to_follow))
50 |
51 | me.stop_following(user_to_follow)
52 | self.assertEqual(0,len(me.followees))
53 | self.assertEqual(0,len(user_to_follow.followers))
54 | self.assertEqual(False,me.following(user_to_follow))
55 |
56 | def test_user_find_by_name(self):
57 | user = User.create(self.params['username'],self.params['password'])
58 | user_found = User.find_by_username(self.params['username'])
59 | self.assertEqual(user.id,user_found.id)
60 | self.assertEqual(self.params['username'],user_found.username)
61 | user_not_found = User.find_by_username('not_found')
62 | self.assertEqual(None,user_not_found)
63 |
64 | def test_user_find_by_id(self):
65 | user = User.create(self.params['username'],self.params['password'])
66 | user_found = User.find_by_id(user.id)
67 | self.assertEqual(user.username,user_found.username)
68 | user_not_found = User.find_by_id(2)
69 | self.assertEqual(None,user_not_found)
70 |
71 | def test_create_post(self):
72 | user = User.create(self.params['username'],self.params['password'])
73 | Post.create(user,self.params['post'])
74 | self.assertEqual(1,len(user.posts()))
75 | self.assertEqual(1,user.posts()[0].id)
76 | self.assertEqual(self.params['post'],user.posts()[0].content)
77 |
78 | def test_post_find_by_id(self):
79 | user = User.create(self.params['username'],self.params['password'])
80 | Post.create(user,self.params['post'])
81 | post_found = Post.find_by_id(1)
82 | self.assertEqual(1,post_found.id)
83 | self.assertEqual(user.id,int(post_found.user_id)) #shouldn't need int()
84 | self.assertEqual(self.params['username'],post_found.user.username)
85 |
86 |
87 | def test_create_post_with_mention(self):
88 | user = User.create(self.params['username'],self.params['password'])
89 | content_with_mention = self.params['post'] + '@' + self.params['username']
90 | Post.create(user,content_with_mention)
91 | self.assertEqual(1,len(user.mentions()))
92 |
93 | def test_dispatch_post_to_followers(self):
94 | user_to_follow = User.create('anonymous','password')
95 | me = User.create(self.params['username'],self.params['password'])
96 | me.follow(user_to_follow)
97 | Post.create(user_to_follow,self.params['post'])
98 | self.assertEqual(1,len(me.timeline()))
99 | self.assertEqual(1,len(me.timeline()))
100 |
101 | if __name__ == '__main__':
102 | unittest.main()
--------------------------------------------------------------------------------
/views/home_not_logged.tpl:
--------------------------------------------------------------------------------
1 | %#this is the homepage :)
2 | %include shared/header.tpl header='home', logged=logged
3 | home page
4 | %include shared/footer.tpl
--------------------------------------------------------------------------------
/views/login.tpl:
--------------------------------------------------------------------------------
1 | %#login page
2 | %include shared/header.tpl header=page,logged=logged
3 |
4 |
13 |
14 |
23 | %include shared/footer.tpl
--------------------------------------------------------------------------------
/views/mentions.tpl:
--------------------------------------------------------------------------------
1 | %#list of currents posts
2 | %include shared/header.tpl header=page,logged=logged
3 |
4 |
What's happening?
5 |
12 |
13 |
18 |
19 | %include shared/side.tpl username=username,counts=counts
20 |
21 | %include shared/footer.tpl
--------------------------------------------------------------------------------
/views/shared/footer.tpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |