├── .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 |
5 |

Login

6 | %if error_login: 7 |

wrong username/password

8 | %end 9 |

10 |

11 |

12 |
13 | 14 |
15 |

Sign up

16 | %if error_signup: 17 |

username already exists

18 | %end 19 |

20 |

21 |

22 |
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 |
6 | 7 |
8 |

{{posts[0].content}}

9 | 10 |
11 |
12 | 13 |
14 | %for tweet in mentions: 15 |

{{tweet.user.username}} {{tweet.content}}permalink

16 | %end 17 |
18 |
19 | %include shared/side.tpl username=username,counts=counts 20 | 21 | %include shared/footer.tpl -------------------------------------------------------------------------------- /views/shared/footer.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /views/shared/form.tpl: -------------------------------------------------------------------------------- 1 |

What's happening?

2 |
3 | 4 |
5 | %if tweet: 6 |

{{tweet.content}}

7 | %else: 8 |

really ? nothing's happening ?

9 | %end 10 | 11 |
12 |
-------------------------------------------------------------------------------- /views/shared/header.tpl: -------------------------------------------------------------------------------- 1 | %#header :) 2 | 3 | 4 | 5 | 6 | Retwis-py 7 | 8 | 9 | 10 | %include shared/nav.tpl logged=logged 11 |
-------------------------------------------------------------------------------- /views/shared/nav.tpl: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /views/shared/side.tpl: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{username}} 4 | {{counts[2]}} tweets 5 |

6 | -------------------------------------------------------------------------------- /views/single.tpl: -------------------------------------------------------------------------------- 1 | %#single post template 2 | %include shared/header.tpl header=page,logged=logged 3 |
4 |

{{tweet.user.username}} {{tweet.content}}permalink

5 |
6 | %include shared/footer.tpl -------------------------------------------------------------------------------- /views/timeline.tpl: -------------------------------------------------------------------------------- 1 | %#list of currents posts 2 | %include shared/header.tpl header=page,logged=logged 3 |
4 | %include shared/form.tpl tweet=last_tweet 5 | 6 |
7 | %for tweet in timeline: 8 |

{{tweet.user.username}} {{tweet.content}}permalink

9 | %end 10 |
11 |
12 | %include shared/side.tpl username=username,counts=counts 13 | 14 | %include shared/footer.tpl -------------------------------------------------------------------------------- /views/user.tpl: -------------------------------------------------------------------------------- 1 | %#list of currents posts 2 | %include shared/header.tpl header=page,logged=logged 3 |
4 | %if logged and not himself: 5 | %if is_following: 6 |
7 |

8 |
9 | %else: 10 | 13 | %end 14 | %end 15 |
16 | %if posts: 17 | %for tweet in posts: 18 |

{{tweet.user.username}} {{tweet.content}}permalink

19 | %end 20 | %else: 21 |

{{username}} has posted any tweet yet

22 | %end 23 |
24 |
25 | %include shared/side.tpl username=username,counts=counts 26 | 27 | %include shared/footer.tpl --------------------------------------------------------------------------------