Jimmy Yuen Ho Wong avatar Jimmy Yuen Ho Wong committed 1276425

initial commit

Comments (0)

Files changed (21)

+syntax: glob
+dev.cfg
+loggingcfg.py
+test.cfg
+static/css/*.css
+.rvmrc
+.sass-cache/*
+templates/__jinja2*
+doc/_build
+.coverage
+cover
+*,cover
+*.mo
+bpython.desktop
+.pydevproject
+.settings
+.project
+build
+temp
+dist
+MANIFEST
+.Python
+data
+bin
+lib
+log
+include
+.ropeproject
+man
+.DS_Store
+[easy_install]
+zip_ok = False
+[global]
+engine.logging.on = True
+engine.sqlalchemy.on = True
+
+[/]
+tools.proxy.on = True
+tools.orm_session.on = True
+tools.sessions.on = True
+tools.sessions.storage_type = "redis"
+tools.staticdir.root = "/Users/wyuenho/Documents/workspace/pythonhk/static"
+tools.staticfile.root = "/Users/wyuenho/Documents/workspace/pythonhk/static"
+
+[/css]
+tools.staticdir.on = True
+tools.staticdir.dir = "css"
+
+[/js]
+tools.staticdir.on = True
+tools.staticdir.dir = "js"
+
+[/img]
+tools.staticdir.on = True
+tools.staticdir.dir = "img"
+
+[/favicon.ico]
+tools.staticfile.on = True
+tools.staticfile.filename = "img/favicon.ico"
+
+[view]
+jinja2.trim_blocks = True
+jinja2.loader = jinja2.FileSystemLoader("/Users/wyuenho/Documents/workspace/pythonhk/templates")
+jinja2.auto_reload = True
+jinja2.bytecode_cache = jinja2.FileSystemBytecodeCache("/Users/wyuenho/Documents/workspace/pythonhk/templates")
+
+[sqlalchemy_engine]
+sqlalchemy.url = "postgresql+psycopg2://pythonhk@localhost/pythonhk"
+sqlalchemy.pool_recycle = 3600
+
+[redis]
+host = "localhost"
+port = 6379

loggingcfg.py.sample

+import sys
+import os.path as path
+
+config = {'version': 1,
+          'disable_existing_loggers': False,
+          'loggers':
+              {'root':
+                   {'level': 'WARNING',
+                    'handlers': ['rfile']},
+               'pythonhk':
+                   {'level': 'INFO',
+                    'handlers': ['rfile'],
+                    'propagate': False},
+               'sqlalchemy':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.engine':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.dialects':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.pool':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm.attributes':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm.mapper':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm.unitofwork':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm.strategies':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               'sqlalchemy.orm.sync':
+                   {'level': 'WARNING',
+                    'handlers': ['sa'],
+                    'propagate': False},
+               },
+          'handlers':
+              {'stdout':
+                   {'class': 'logging.StreamHandler',
+                    'formatter': 'debug',
+                    'stream': sys.stdout},
+               'stderr':
+                   {'class': 'logging.StreamHandler',
+                    'formatter': 'debug',
+                    'stream': sys.stderr},
+               'rfile':
+                   {'class': 'logging.handlers.RotatingFileHandler',
+                    'formatter': 'debug',
+                    'filename': path.join(path.dirname(__file__), 'log/pythonhk.log'),
+                    'maxBytes': 1048576,
+                    'backupCount': 5,
+                    'encoding': 'utf8'},
+               'trfile':
+                   {'class': 'logging.handlers.TimedRotatingFileHandler',
+                    'formatter': 'error',
+                    'filename': path.join(path.dirname(__file__), 'log/pythonhk.log'),
+                    'when': 'midnight',
+                    'backupCount': 60,
+                    'encoding': 'utf8'},
+               'sa':
+                   {'class': 'logging.handlers.RotatingFileHandler',
+                    'formatter': 'debug',
+                    'filename': path.join(path.dirname(__file__), 'log/pythonhk.log'),
+                    'maxBytes': 1048576,
+                    'backupCount': 5,
+                    'encoding': 'utf8'}
+               },
+          'formatters':
+              {'debug':
+                   {'format': '%(asctime)s [%(levelname)s] %(name)s Thread(id=%(thread)d, name="%(threadName)s") %(message)s'},
+               'info':
+                   {'format': '%(asctime)s [%(levelname)s] %(name)s %(message)s'},
+               'error':
+                   {'format': '%(asctime)s [%(levelname)s] %(name)s Thread(id=%(thread)d, name="%(threadName)s") %(module)s.%(funcName)s: line %(lineno)d %(message)s'}
+               }
+          }
+CherryPy==3.2.2
+Jinja2==2.6
+Markdown==2.1.0
+Paste==1.7.5.1
+Pygments==1.4
+SQLAlchemy==0.7.3
+Sphinx==1.1.2
+Tempita==0.5.1
+WebError==0.10.3
+WebOb==1.2b2
+distribute==0.6.24
+docutils==0.8.1
+nose==1.1.2
+nose-testconfig==0.8
+redis==2.4.10
+repoze.who==2.0
+simplejson==2.2.1
+zope.interface==3.8.0
+[egg_info]
+tag_build = dev
+tag_date = true
+from setuptools import setup, find_packages
+
+setup(name='pythonhk',
+      version='0.1',
+      author='HKPUG',
+      author_email='jimmy.wong@hometasty.com',
+      entry_points={'console_scripts': ['start-pythonhk = pythonhk.main:main',
+                                        'pythonhk-console = pythonhk.console:main']},
+      package_dir={'': 'src'},
+      packages=find_packages('src',
+                             exclude=["*.tests", "*.tests.*", "tests.*", "tests"]),
+      namespace_packages=['pythonhk'],
+      zip_safe=False,
+      install_requires=[])

src/pythonhk/__init__.py

+__import__('pkg_resources').declare_namespace(__name__)

src/pythonhk/api.py

+from pythonhk.models import User
+
+def find_user_by_id(session, id):
+    return session.query(User).get(id)
+
+def delete_user_by_id(session, id):
+    return session.query(User).filter(User.id == id).delete()
+

src/pythonhk/console.py

+import os
+import sys
+from argparse import ArgumentParser
+from code import InteractiveConsole
+
+import cherrypy
+from sqlalchemy.engine import engine_from_config
+
+class Console(InteractiveConsole):
+
+    def __init__(self, confs, prompt):
+
+        if confs:
+            if isinstance(confs, (tuple, list)):
+                confs = list(confs)
+            else:
+                confs = [confs]
+
+            config = {}
+            for conf in confs:
+                cherrypy._cpconfig.merge(config, conf)
+            self.config = config
+        else:
+            self.config = None
+
+        try:
+            import readline
+        except ImportError, e:
+            print e
+        else:
+            import rlcompleter
+
+        startupfile = os.environ.get("PYTHONSTARTUP")
+        if startupfile: execfile(startupfile, {}, {})
+
+        sys.ps1 = "[%s]>>> " % prompt
+        sys.ps2 = "[%s]... " % prompt
+
+        self.prompt = prompt
+
+        InteractiveConsole.__init__(self, locals=self.get_locals())
+
+    def get_locals(self):
+        import sys, os, os.path, time, datetime, pprint, inspect
+        import sqlalchemy
+        import sqlalchemy.orm
+        import cherrypy
+        from pythonhk import models
+
+        lcls = dict(locals())
+
+        if getattr(models, '__all__'):
+            for name in models.__all__:
+                lcls[name] = getattr(models, name)
+        else:
+            for name, obj in vars(models).iteritems():
+                if not name.startswith("_"):
+                    lcls[name] = obj
+
+        if self.config:
+            section = self.config.get("sqlalchemy_engine")
+            if section:
+                engine = engine_from_config(section)
+                Session = sqlalchemy.orm.sessionmaker()
+                Session.configure(bind=engine)
+                metadata = models.metadata
+                metadata.bind = engine
+                lcls['create_all'] = metadata.create_all
+                lcls['drop_all'] = metadata.drop_all
+                lcls['session'] = session = Session()
+                import atexit
+                atexit.register(session.close)
+
+        return lcls
+
+    def raw_input(self, *args, **kw):
+        try:
+            r = InteractiveConsole.raw_input(self, *args, **kw)
+            for encoding in (getattr(sys.stdin, 'encoding', None),
+                             sys.getdefaultencoding(), 'utf-8', 'latin-1'):
+                if encoding:
+                    try:
+                        return r.decode(encoding)
+                    except UnicodeError:
+                        pass
+                    return r
+        except EOFError:
+            self.write(os.linesep)
+            session = self.locals.get("session")
+            if session is not None and \
+                getattr(session, "new", None) or \
+                getattr(session, "dirty", None) or \
+                getattr(session, "deleted", None):
+
+                r = raw_input("Do you wish to commit your "
+                              "database changes? [Y/n]")
+                if not r.lower().startswith("n"):
+                    self.push("session.flush()")
+            raise
+
+def main():
+
+    argparser = ArgumentParser(description="Playground for prototyping SQLAlchemy queries against the models and experimenting with CherryPy's tools.")
+    argparser.add_argument("-p", "--prompt", default="pythonhk")
+    argparser.add_argument("-f", "--config-file", nargs=1, help="The configuration file to set up the SQLAlchemy session. If any.")
+
+    args = argparser.parse_args()
+
+    banner = """
+*****************************************************************************
+* If the configuration file you specified contains a [sqlalchemy_engine]    *
+* section, a default SQLAlchemy engine and session should have been created *
+* for you automatically already.                                            *
+*****************************************************************************
+"""
+
+    Console(args.config_file, args.prompt).interact(banner)

src/pythonhk/controllers.py

+from cherrypy._cperror import HTTPRedirect
+try:
+    import simplejson as json
+except ImportError:
+    import json
+
+import cherrypy
+
+from pythonhk import api
+
+class Root(object):
+
+    @cherrypy.expose
+    def index(self):
+        raise HTTPRedirect("/admin")
+
+
+class AdminRoot(object):
+
+    @cherrypy.expose
+    def index(self):
+        pass

src/pythonhk/cp/__init__.py

+import cherrypy
+from tools import SQLAlchemySessionTool
+cherrypy.tools.orm_session = SQLAlchemySessionTool()
+
+from cherrypy.lib import sessions
+from session import RedisSession
+sessions.RedisSession = RedisSession

src/pythonhk/cp/plugins.py

+import importlib
+import inspect
+import logging
+import os.path
+import sys
+from sqlalchemy.engine import engine_from_config
+
+try:
+    from logging.config import dictConfig
+except ImportError:
+    from logutils.dictconfig import dictConfig
+
+from cherrypy.process.plugins import SimplePlugin
+
+
+__all__ = ['LoggingPlugin', 'SQLAlchemyPlugin']
+
+
+class LoggingPlugin(SimplePlugin):
+    """ Sets up process-wide application specific loggers
+    """
+
+    def __init__(self, bus,
+                 raiseExceptions=logging.raiseExceptions,
+                 config_mod='./loggingcfg'):
+
+        SimplePlugin.__init__(self, bus)
+
+        self.raiseExceptions = raiseExceptions
+        self.config_mod = config_mod
+
+    def start(self):
+        try:
+            logging.raiseExceptions = self.raiseExceptions
+
+            if ((not inspect.ismodule(self.config_mod)) and
+                    isinstance(self.config_mod, basestring)):
+                path_to_mod = os.path.abspath(self.config_mod)
+                mod_path, mod_name = os.path.split(path_to_mod)
+                sys.path.insert(0, mod_path)
+                self.config_mod = importlib.import_module(mod_name)
+                del sys.path[0]
+
+            dictConfig(self.config_mod.config)
+
+            self.bus.log("Loggers configured.")
+
+        except Exception:
+            self.bus.log(traceback=True)
+    start.priority = 80
+
+    def exit(self):
+        self.bus.log("Flushing and closing loggers.")
+        logging.shutdown()
+
+
+class SQLAlchemyPlugin(SimplePlugin):
+    """Sets up process-wide SQLAlchemy engines.
+    
+    This plugin setups basic machinary to attach and clean up engine bindings.
+    Engine bindings are in the exact format as the `binds` keyword in
+    `Session.configure()`.
+    
+    In the future in case we ever get to horizontal sharding, this plugin will
+    need to be updated.
+    """
+
+    def __init__(self, bus):
+        SimplePlugin.__init__(self, bus)
+
+    def start(self):
+        pass
+    start.priority = 83
+
+    def graceful(self):
+        if hasattr(self, "engine_bindings"):
+            engine_bindings = self.engine_bindings
+            for engine in engine_bindings.itervalues():
+                self.bus.log("Disposing SQLAlchemy engine %s ..." % engine.url)
+                engine.dispose()
+        elif hasattr(self, "engine"):
+            engine = self.engine
+            self.bus.log("Disposing SQLAlchemy engine %s ..." % engine.url)
+            engine.dispose()
+
+    def configure_engines(self, config, prefix="sqlalchemy_engine"):
+        """Sets up engine bindings based on the given config.
+        
+        Given a configuration dictionary, and optionaly a key `prefix`, this
+        method iterates all its sections looking for sections with names that
+        start with `prefix`. The suffix of the name is fully qualified name to
+        a model. A SQLAlchemy engine is then set up with the section value as
+        configuration parameters to `sqlalchemy.engine_form_config`. The
+        configured engine bindings are to be found in
+        `cherrypy.engine.sqlalchemy.engine_bindings` (or wherever you've
+        attached this plugin to).
+        
+        If there is a section that is named exactly the same as the `prefix`,
+        that section's values are use to configure only one SQLAlchemy engine
+        attached to `cherrypy.engine.sqlalchemy.engine`.
+        
+        Example::
+        
+            # The model to be imported starts after the _ following the prefix
+            [sqlalchemy_engine_package.models.User]
+            sqlalchemy.url = ...
+            sqlalchemy.pool_recycle = ...
+            
+            # If this section exists, only 1 engine will be configured
+            [sqlalchemy_engine]
+            sqlalchemy.url = ...
+        
+        
+        :py:func: sqlalchemy.engine_from_config
+        """
+
+        engine_bindings = {}
+
+        if prefix in config:
+            section = config[prefix]
+            self.engine = engine_from_config(section)
+        else:
+            for section_name, section in config.iteritems():
+                if section_name.startswith(prefix):
+                    model_fqn = section_name[len(prefix) + 1:]
+                    model_fqn_parts = model_fqn.rsplit('.', 1)
+                    model_mod = __import__(model_fqn_parts[0], globals(), locals(), [model_fqn_parts[1]])
+                    model = getattr(model_mod, model_fqn_parts[1])
+                    engine_bindings[model] = engine_from_config(section)
+            self.engine_bindings = engine_bindings
+
+        self.bus.log("SQLAlchemy engines configured")

src/pythonhk/cp/session.py

+import cPickle as pickle
+import logging
+import threading
+import time
+
+from pprint import pformat
+
+from cherrypy.lib import sessions
+from redis import Redis as _RedisClient
+
+
+logger = logging.getLogger(__name__)
+
+
+class RedisSession(sessions.Session):
+
+    locks = {}
+
+    prefix = "cp-session:"
+
+    @classmethod
+    def setup(cls, **kwargs):
+        for k, v in kwargs.items():
+            setattr(cls, k, v)
+        cls.cache = cache = _RedisClient(**kwargs)
+        redis_info = cache.info()
+        logger.info("Redis server ready.\n%s" % pformat(redis_info))
+
+    def _exists(self):
+        return self.cache.exists(self.prefix + self.id)
+
+    def _load(self):
+        data = self.cache.get(self.prefix + self.id)
+        if data:
+            retval = pickle.loads(data)
+            return retval
+
+    def _save(self, expiration_time):
+        key = self.prefix + self.id
+        td = int(time.mktime(expiration_time.timetuple()))
+
+        data = pickle.dumps((self._data, expiration_time),
+                            pickle.HIGHEST_PROTOCOL)
+
+        def critical_section(pipe):
+            pipe.multi()
+            pipe.set(key, data)
+            pipe.expireat(key, td)
+
+        self.cache.transaction(critical_section, key)
+
+    def _delete(self):
+        self.cache.delete(self.prefix + self.id)
+
+    def acquire_lock(self):
+        self.locked = True
+        self.locks.setdefault(self.id, threading.RLock()).acquire()
+
+    def release_lock(self):
+        self.locks[self.id].release()
+        self.locked = False
+
+    def __len__(self):
+        """Return the number of active sessions."""
+        keys = self.cache.keys(self.prefix + '*')
+        return len(keys)

src/pythonhk/cp/tools.py

+import logging
+
+from sys import exc_info
+
+import cherrypy
+from cherrypy import Tool, InternalRedirect, HTTPRedirect
+
+from sqlalchemy.orm import scoped_session, sessionmaker
+from sqlalchemy.exc import SQLAlchemyError
+
+
+logger = logging.getLogger(__name__)
+
+
+class SQLAlchemySessionTool(Tool):
+    """A CherryPy tool to process SQLAlchemy ORM sessions for requests.
+
+    This tools sets up a scoped, possibly multi-engine SQLAlchemy ORM session to
+    `cherrypy.request.orm_session` at the beginning of a request. At the end of
+    the request, this tool will commit the session if neccessary, and rollback
+    if errors occured. Once this is done the session is removed from the
+    request.
+
+    As this tool hooks up _2_ callables to the request, this tools will also
+    accept 2 `priority` options - `on_start_resource_priority` and
+    `before_finalize_priority`. The `priority` option is still accepted as a
+    default for both `attach` and `finalize`.
+    """
+
+    # A list of exceptions that do not cause rollback. Extendable by apps.
+    passable_exceptions = [InternalRedirect, HTTPRedirect]
+
+    def __init__(self):
+        doc = self.__doc__
+        Tool.__init__(self, "on_start_resource", self.on_start_resource)
+        # Revert the changed doc Tool.__init__ did.
+        self.__doc__ = doc
+        # Remove the self attr set by _setargs().
+        del self.self
+
+    def __call__(self, *args, **kwargs):
+        raise NotImplementedError, "This %r instance cannot be called directly." % \
+            self.__class__.__name__
+
+    def _setup(self):
+        request = cherrypy.request
+        conf = self._merged_args()
+
+        if "bindings" in conf:
+            self.bindings = conf["bindings"]
+        if "passable_exceptions" in conf:
+            self.passable_exceptions = conf["passable_exceptions"]
+
+        on_start_resource_priority = conf.pop("on_start_resource_priority", None)
+        if on_start_resource_priority is None:
+            on_start_resource_priority = getattr(self.on_start_resource, "priority",
+                                                 self._priority)
+        request.hooks.attach(self._point, self.on_start_resource,
+                             priority=on_start_resource_priority)
+
+        before_finalize_priority = conf.pop("before_finalize_priority", None)
+        if before_finalize_priority is None:
+            before_finalize_priority = getattr(self.before_finalize, "priority",
+                                               self._priority)
+        request.hooks.attach("before_finalize", self.before_finalize,
+                             priority=before_finalize_priority)
+
+    def on_start_resource(self):
+        if hasattr(self, "bindings"):
+
+            bindings = self.bindings
+
+            if len(bindings) > 1:
+                Session = scoped_session(sessionmaker(twophase=True))
+            else:
+                Session = scoped_session(sessionmaker())
+
+            session_bindings = {}
+            engine_bindings = cherrypy.engine.sqlalchemy.engine_bindings
+            for binding in bindings:
+                session_bindings[binding] = engine_bindings[binding]
+
+            Session.configure(binds=session_bindings)
+
+        else:
+            Session = scoped_session(sessionmaker())
+            Session.configure(bind=cherrypy.engine.sqlalchemy.engine)
+
+        cherrypy.request.orm_session = Session
+
+    def before_finalize(self):
+
+        req = cherrypy.request
+        session = req.orm_session
+
+        typ, value, trace = exc_info()
+
+        # Rollback if exception raised in request handler
+        if typ and typ not in self.passable_exceptions:
+            session.rollback() # undoes what has been flushed
+            session.expunge_all() # undoes what has not been flushed
+            # deconfigure (unbind) session so it is ready for next request
+            session.remove()
+            return
+
+        try:
+            if session.new or session.deleted or session.dirty:
+                session.commit()
+                return
+        except SQLAlchemyError, e:
+            logger.error(e, exc_info=True)
+            session.rollback()    # undoes what has been flushed
+            session.expunge_all() # undoes what has not been flushed
+            raise                 # let this exception propagate
+        finally:
+            # deconfigure (unbind) session so it is ready for next request
+            session.remove()

src/pythonhk/main.py

+import os
+import sys
+from argparse import ArgumentParser
+
+import cherrypy
+from cherrypy.process import servers
+from cherrypy.process.plugins import Daemonizer, DropPrivileges, PIDFile
+
+from jinja2 import Environment
+from redis.client import Redis
+
+from pythonhk.cp.plugins import *
+from pythonhk.controllers import Root as WebRoot
+from pythonhk.rest_api import api_routes, error_handlers
+
+
+def init(options, confs=None):
+
+    engine = cherrypy.engine
+
+    if confs:
+        if isinstance(confs, (tuple, list)):
+            confs = list(confs)
+        else:
+            confs = [confs]
+    else:
+        confs = [os.getcwdu() + "/dev.cfg"]
+
+    config = {}
+    for conf in confs:
+        cherrypy._cpconfig.merge(config, conf)
+
+    if options.environment is not None:
+
+        options.environment = options.environment.strip().lower()
+
+        # setup the excellent WebError WSGI middleware to help debugging.
+        if options.environment == "weberror":
+
+            cherrypy._cpconfig.environments["weberror"] = {
+                "log.wsgi": True,
+                "request.throw_errors": True,
+                "log.screen": False,
+                "engine.autoreload_on": False,
+                }
+
+            def remove_error_options(section):
+                section.pop("request.handler_error", None)
+                section.pop("request.error_response", None)
+                section.pop("tools.err_redirect.on", None)
+                section.pop("tools.log_headers.on", None)
+                section.pop("tools.log_tracebacks.on", None)
+
+                for k in section.copy().iterkeys():
+                    if k.startswith("error_page.") or \
+                            k.startswith("request.error_page."):
+                        section.pop(k)
+
+            if "global" in config:
+                global_section = config["global"]
+            else:
+                global_section = config
+
+            remove_error_options(global_section)
+
+            for k in config.iterkeys():
+                if k.startswith("/"):
+                    section = config[k]
+                    remove_error_options(section)
+
+            root_section = config["/"]
+            pipeline = []
+            if "wsgi.pipeline" in root_section:
+                pipeline = root_section["wsgi.pipeline"]
+
+            from weberror.evalexception import EvalException
+            pipeline.insert(0, ("evalexc", EvalException))
+
+            root_section["wsgi.pipeline"] = pipeline
+
+        if "global" in config:
+            config["global"]["environment"] = options.environment
+        else:
+            config["environment"] = options.environment
+
+    cherrypy.config.update(config)
+
+    return config
+
+class Application(cherrypy.Application):
+
+    def merge(self, config):
+        cherrypy.Application.merge(self, config)
+
+        jinja2_config = dict([(k.replace('jinja2.', ''), v) for k, v in self.config.get('view', {}).iteritems() if k.startswith('jinja2.')])
+        self.template_loader = Environment(**jinja2_config)
+
+        self.cache = Redis(**dict([(k, v) for k, v in self.config.get('redis', {}).iteritems()]))
+
+def main():
+    parser = ArgumentParser(description="Start a cherrypy instance with  running")
+    parser.add_argument("configfiles", metavar="FILE", type=file, nargs="*",
+                        default=['dev.cfg'],
+                        help="config file(s). default: %(default)s")
+    parser.add_argument("-l", "--loggingcfg", default="./loggingcfg",
+                        help="path to the config module for logging")
+    parser.add_argument("-b", "--bind", dest="binding",
+                         default="127.0.0.1:8080",
+                         help="the address and port to bind to, default: '%(default)s'")
+    parser.add_argument("-e", "--environment", dest="environment",
+                         default=None,
+                         help="apply the given config environment")
+    parser.add_argument("-f", action="store_true", dest="fastcgi",
+                         help="start a fastcgi server instead of the default HTTP server")
+    parser.add_argument("-s", action="store_true", dest="scgi",
+                         help="start a scgi server instead of the default HTTP server")
+    parser.add_argument("-d", "--daemonize", action="store_true",
+                         default=False,
+                         help="run the server as a daemon. [default: %(default)s]")
+    parser.add_argument("-p", "--drop-privilege", action="store_true",
+                         default=False,
+                         help="drop privilege to separately specified umask, "
+                         "uid and gid. [default: %(default)s]")
+    parser.add_argument('-P', '--pidfile', dest='pidfile', default=None,
+                         help="store the process id in the given file")
+    parser.add_argument("-u", "--uid", default="www",
+                         help="setuid to uid [default: %(default)s]")
+    parser.add_argument("-g", "--gid", default="www",
+                         help="setgid to gid [default: %(default)s]")
+    parser.add_argument("-m", "--umask", default="022", type=int,
+                         help="set umask [default: %(default)s]")
+    parser.add_argument('-q', '--Path', action="append", dest='path',
+                        help="add the given paths to sys.path")
+
+
+    args = parser.parse_args()
+
+    if args.path:
+        for p in args.path:
+            sys.path.insert(0, p)
+
+    cpengine = cherrypy.engine
+
+    if args.binding:
+        address, port = args.binding.strip().split(":")
+        cherrypy.server.socket_host = address
+        cherrypy.server.socket_port = int(port)
+
+    if args.daemonize:
+        cherrypy.config.update({'log.screen': False})
+        Daemonizer(cpengine).subscribe()
+
+    if args.drop_privilege:
+        cherrypy.config.update({'cpengine.autoreload_on': False})
+        DropPrivileges(cpengine, umask=args.umask or 022,
+                       uid=args.uid or "www",
+                       gid=args.gid or "www").subscribe()
+
+    if args.pidfile:
+        PIDFile(cpengine, args.pidfile).subscribe()
+
+    fastcgi, scgi = getattr(args, 'fastcgi'), getattr(args, 'scgi')
+    if fastcgi and scgi:
+        cherrypy.log.error("You may only specify one of the fastcgi and "
+                           "scgi options.", 'ENGINE')
+        sys.exit(1)
+    elif fastcgi or scgi:
+        # Turn off autoreload when using *cgi.
+        cherrypy.config.update({'cpengine.autoreload_on': False})
+        # Turn off the default HTTP server (which is subscribed by default).
+        cherrypy.server.unsubscribe()
+
+        addr = cherrypy.server.bind_addr
+        if fastcgi:
+            f = servers.FlupFCGIServer(application=cherrypy.tree,
+                                       bindAddress=addr)
+        elif scgi:
+            f = servers.FlupSCGIServer(application=cherrypy.tree,
+                                       bindAddress=addr)
+        s = servers.ServerAdapter(cpengine, httpserver=f, bind_addr=addr)
+        s.subscribe()
+
+    if hasattr(cpengine, 'signal_handler'):
+        cpengine.signal_handler.subscribe()
+
+    # for win32 only, maybe not needed cos we'll never deploy on winblows
+    if hasattr(cpengine, "console_control_handler"):
+        cpengine.console_control_handler.subscribe()
+
+    cpengine.logging = LoggingPlugin(cpengine, config_mod=args.loggingcfg)
+    cpengine.sqlalchemy = SQLAlchemyPlugin(cpengine)
+
+    config = init(args, args.configfiles)
+
+    cpengine.sqlalchemy.configure_engines(config)
+
+    cherrypy.tree.mount(Application(WebRoot(), '', config))
+
+    api_conf = {"/": {"request.dispatch": api_routes,
+                      "tools.proxy.on": True,
+                      "tools.orm_session.on": True,
+                      "tools.sessions.on": True,
+                      "tools.sessions.storage_type": "redis"}}
+    api_conf["/"].update(error_handlers)
+    cherrypy.tree.mount(None, script_name="/api", config=api_conf)
+    cherrypy.tree.mount(None, script_name="/api/v1", config=api_conf)
+
+    # Always start the cpengine; this will start all other services
+    try:
+        cpengine.start()
+    except:
+        # Assume the error has been logged already via bus.log.
+        sys.exit(1)
+    else:
+        cpengine.block()

src/pythonhk/models.py

+import calendar
+import os.path
+from base64 import urlsafe_b64decode, urlsafe_b64encode
+from datetime import datetime, timedelta, date, time
+from hashlib import sha256
+from urlparse import urlparse, urlunparse
+
+from Crypto import Random
+
+from sqlalchemy import ForeignKey, Table, Column, Integer, Unicode, \
+    UnicodeText, Date, DateTime, Time, String, BigInteger, Enum, SmallInteger, \
+    Boolean, func, event
+from sqlalchemy.orm import relationship, deferred, backref
+from sqlalchemy.orm.interfaces import PropComparator
+from sqlalchemy.orm import synonym
+from sqlalchemy.ext.declarative import synonym_for, declarative_base, \
+    comparable_using
+from sqlalchemy.schema import AddConstraint, UniqueConstraint
+
+__all__ = ["User", "Group", "Permission"]
+
+
+Base = declarative_base()
+metadata = Base.metadata
+
+
+group_permission_table = Table("group_permission",
+                                metadata,
+                                Column("group_id",
+                                       Integer,
+                                       ForeignKey("group.id",
+                                                  onupdate="CASCADE",
+                                                  ondelete="CASCADE"),
+                                       primary_key=True),
+                                Column("permission_id",
+                                       Integer,
+                                       ForeignKey("permission.id",
+                                                  onupdate="CASCADE",
+                                                  ondelete="CASCADE"),
+                                       primary_key=True))
+
+
+user_group_table = Table("user_group",
+                          metadata,
+                          Column("user_id", Integer,
+                                 ForeignKey("user.id",
+                                            onupdate="CASCADE",
+                                            ondelete="CASCADE"),
+                                 primary_key=True),
+                          Column("group_id", Integer,
+                                 ForeignKey("group.id",
+                                            onupdate="CASCADE",
+                                            ondelete="CASCADE"),
+                                 primary_key=True))
+
+
+class _AgeComparator(PropComparator):
+
+    def __clause_element__(self):
+        return func.date_part("year", func.age(self.mapper.c.date_of_birth))
+
+    def operate(self, op, *args, **kwargs):
+        return op(self.__clause_element__(), *args, **kwargs)
+
+    def reverse_operate(self, op, *args, **kwargs):
+        return op(self.__clause_element__(), *args, **kwargs)
+
+
+class User(Base):
+    """
+    This class represents a user. Only minimal demographical information
+    is stored here.
+    """
+
+    __tablename__ = "user"
+
+
+    def __init__(self, **kwargs):
+        super(User, self).__init__(**kwargs)
+
+    id = Column(Integer, autoincrement=True, primary_key=True)
+    """SERIAL PRIMARY KEY"""
+#    fbid = Column(BigInteger, unique=True, index=True)
+#    """BIGINT Facebook ID, indexed"""
+#    username = Column(Unicode(80), unique=True, index=True)
+#    """VARCHAR(80) UNIQUE, indexed"""
+#    displayname = Column(Unicode(80), nullable=False)
+#    """VARCHAR(80) NOT NULL"""
+    email = Column(Unicode(80), unique=True, nullable=False, index=True)
+    """VARCHAR(80) UNIQUE NOT NULL, indexed"""
+
+    _salt = Column("salt", String(12))
+
+    @synonym_for("_salt")
+    @property
+    def salt(self):
+        """Generates a cryptographically random salt and sets its Base64 encoded
+        version to the salt column, and returns the encoded salt.
+        """
+        if not self.id and not self._salt:
+            rand = Random.new()
+            self._salt = urlsafe_b64encode(rand.read(8))
+            rand.close()
+
+        return self._salt
+
+    # 40 is the length of the SHA-256 encoded string length
+    _password = Column("password", Unicode(64))
+    def __set_password(self, password):
+        self._password = self.__encrypt_password(password,
+                                                 urlsafe_b64decode(self.salt))
+    def __get_password(self):
+        return self._password
+    password = synonym("_password", descriptor=property(__get_password,
+                                                        __set_password))
+    """VARCHAR(64) because len(base64(sha256(password, salt))) == 64"""
+
+    def __encrypt_password(self, password, salt):
+        """
+        Encrypts the password with the given salt using SHA-256. The salt must
+        be cryptographically random bytes.
+
+        :param password: the password that was provided by the user to try and
+                         authenticate. This is the clear text version that we
+                         will need to match against the encrypted one in the
+                         database.
+        :type password: basestring
+
+        :param salt: the salt is used to strengthen the supplied password
+                     against dictionary attacks.
+        :type salt: an 8-byte long cryptographically random byte string
+        """
+
+        if isinstance(password, unicode):
+            password_bytes = password.encode("UTF-8")
+        else:
+            password_bytes = password
+
+        hashed_password = sha256()
+        hashed_password.update(password_bytes)
+        hashed_password.update(salt)
+        hashed_password = hashed_password.hexdigest()
+
+        if not isinstance(hashed_password, unicode):
+            hashed_password = hashed_password.decode("UTF-8")
+
+        return hashed_password
+
+    def validate_password(self, password):
+        """Check the password against existing credentials.
+
+        :type password: unicode
+        :param password: clear text password
+        :rtype: bool
+        """
+        return self.password == self.__encrypt_password(password,
+                                                        urlsafe_b64decode(self.salt))
+
+    sex = Column(Enum("m", "f", name="sex"))
+    """ENUM('m','f')"""
+    birthday = Column(Date)
+    """TIMESTAMP without zone"""
+#    bio = deferred(Column(UnicodeText(65536)))
+#    """TEXT"""
+
+    @comparable_using(_AgeComparator)
+    @property
+    def age(self):
+        """Property calculated from (current time - :attr:`User.date_of_birth` - leap days)"""
+        if self.date_of_birth:
+            today = (datetime.utcnow() + timedelta(hours=self.timezone)).date()
+            birthday = self.date_of_birth
+            if isinstance(birthday, datetime):
+                birthday = birthday.date()
+            age = today - (birthday or (today - timedelta(1)))
+            return (age.days - calendar.leapdays(birthday.year, today.year)) / 365
+        return -1
+
+#    locale = Column(String(10), nullable=False)
+#    """VARCHAR(10) NOT NULL len(CLDR lang tag with script) == 10"""
+#    timezone = Column(SmallInteger, nullable=False)
+#    """SMALLINT NOT NULL values range: [-12, 0, 11], timezone offset same as Facebook"""
+    created = Column(DateTime, default=datetime.utcnow, nullable=False)
+    """TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"""
+    lastmodified = Column(DateTime, default=datetime.utcnow, nullable=False)
+    """TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"""
+    lastaccessed = Column(DateTime, default=datetime.utcnow, nullable=False)
+    """TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP"""
+
+
+class Group(Base):
+    """
+    Each group can contain 0 or more users, and 0 or more permissions.
+    """
+
+    __tablename__ = "group"
+
+    id = Column(Integer, autoincrement=True, primary_key=True)
+
+    name = Column(Unicode(40), unique=True, nullable=False)
+    group_name = synonym("name", descriptor=property(lambda self: self.name))
+
+    description = Column(Unicode(255))
+    created = Column(DateTime, default=datetime.utcnow)
+
+    users = relationship(User, secondary=user_group_table, collection_class=set,
+                         backref=backref("groups", collection_class=set))
+
+
+class Permission(Base):
+    """
+    A relationship that determines what each Group can do.
+    """
+
+    __tablename__ = "permission"
+
+    id = Column(Integer, autoincrement=True, primary_key=True)
+
+    name = Column(Unicode(40), unique=True, nullable=False)
+    permission_name = synonym("name", descriptor=property(lambda self: self.name))
+
+    groups = relationship(Group, secondary=group_permission_table,
+                          collection_class=set,
+                          backref=backref("permissions", collection_class=set))
+
+    description = Column(Unicode(255))

src/pythonhk/rest_api.py

+try:
+    import simplejson as json
+except ImportError:
+    import json
+
+import sys
+import traceback
+
+import cherrypy
+
+from urlparse import urlsplit
+
+from cherrypy import HTTPError
+from cherrypy.lib import httputil as cphttputil
+
+from pythonhk import api
+from pythonhk.utils import from_json, to_json
+from pythonhk.models import User
+
+
+class UserController(object):
+
+    _cp_config = {"tools.json_in.on": True}
+
+    @cherrypy.tools.json_out()
+    def create(self, **kwargs):
+        req = cherrypy.request
+        orm_session = req.orm_session
+        user = from_json(req.json, User())
+        orm_session.add(user)
+        orm_session.commit()
+        return to_json(user, includes="age", excludes=("password", "salt"))
+
+    @cherrypy.tools.json_out()
+    def show(self, id, **kwargs):
+        id = int(id)
+        user = api.find_user_by_id(cherrypy.request.orm_session, id)
+        if user:
+            result = to_json(user, includes="age", excludes=("password", "salt"))
+            if "metadata" in kwargs and bool(int(kwargs["metadata"])):
+                result["metadata"] = {"collections": {"trips": cherrypy.url("/%s/trips" % id)}}
+            return result
+        raise HTTPError(404)
+
+    @cherrypy.tools.json_out()
+    def update(self, id, **kwargs):
+        id = int(id)
+        req = cherrypy.request
+        orm_session = req.orm_session
+        user = api.find_user_by_id(orm_session, id)
+        if user:
+            user = from_json(req.json, user)
+            orm_session.commit()
+            return to_json(user, includes="age", excludes=("password", "salt"))
+        raise HTTPError(404)
+
+    def delete(self, id, **kwargs):
+        id = int(id)
+        req = cherrypy.request
+        orm_session = req.orm_session
+        if not api.delete_user_by_id(orm_session, id):
+            raise HTTPError(404)
+
+
+api_routes = cherrypy.dispatch.RoutesDispatcher()
+api_routes.mapper.explicit = False
+
+api_routes.connect("new_user", "/", UserController, action="create",
+                   conditions={"method":["POST"]})
+api_routes.connect("show_user", "/{id}", UserController, action="show",
+                   conditions={"method":["GET"]})
+api_routes.connect("update_user", "/{id}", UserController, action="update",
+                   conditions={"method":["PUT"]})
+api_routes.connect("delete_user", "/{id}", UserController, action="delete",
+                   conditions={"method":["DELETE"]})
+
+
+def generic_error_handler(status, message, traceback, version):
+    code, reason, _ = cphttputil.valid_status(status)
+    result = {"code": code, "reason": reason, "message": message}
+    if hasattr(cherrypy.request, "params"):
+        params = cherrypy.request.params
+        if "debug" in params and params["debug"]:
+            result["traceback"] = traceback
+    return json.dumps(result)
+
+def unexpected_error_hander():
+    (typ, value, tb) = sys.exc_info()
+    if typ:
+        debug = False
+        if hasattr(cherrypy.request, "params"):
+            params = cherrypy.request.params
+            debug = "debug" in params and params["debug"]
+
+        response = cherrypy.response
+        response.headers['Content-Type'] = "application/json"
+        response.headers.pop('Content-Length', None)
+        content = {}
+
+        if isinstance(typ, HTTPError):
+            cherrypy._cperror.clean_headers(value.code)
+            response.status = value.status
+            content = {"code": value.code, "reason": value.reason, "message": value._message}
+        elif isinstance(typ, (TypeError, ValueError, KeyError)):
+            cherrypy._cperror.clean_headers(400)
+            response.status = 400
+            reason, default_message = cphttputil.response_codes[400]
+            content = {"code": 400, "reason": reason, "message": value.message or default_message}
+
+        if cherrypy.serving.request.show_tracebacks or debug:
+            tb = traceback.format_exc()
+            content["traceback"] = tb
+        response.body = json.dumps(content)
+
+
+error_handlers = {"error_page.default": generic_error_handler,
+                  "request.error_response": unexpected_error_hander}
+#error_handlers = {}
+
+def make_rest_api_app(script_name="/api/v1", extra_conf=None):
+    api_conf = {"/": {"request.dispatch": api_routes,
+                      "tools.proxy.on": True,
+                      "tools.orm_session.on": True,
+                      "tools.sessions.on": True,
+                      "tools.sessions.storage_type": "redis"}}
+    api_conf["/"].update(error_handlers)
+    if extra_conf:
+        api_conf.update(extra_conf)
+    return cherrypy.tree.mount(None, script_name=script_name, config=api_conf)
+

src/pythonhk/utils.py

+try:
+    import simplejson as json
+except ImportError:
+    import json
+
+from datetime import date, time, datetime
+
+import dateutil
+
+from cherrypy import HTTPError
+
+def to_json(instance, includes=None, excludes=None, serialize=False):
+
+    includes = set([includes] if isinstance(includes, basestring) else includes and list(includes) or [])
+    excludes = set([excludes] if isinstance(excludes, basestring) else excludes and list(excludes) or [])
+    attrs = set(instance.__table__.c.keys())
+    attrs = includes | attrs - excludes
+
+    doc = {}
+    for k in attrs:
+        v = getattr(instance, k)
+        if not k.startswith("_") and not isinstance(v, (tuple, list, set, frozenset, dict)):
+            if isinstance(v, datetime):
+                v = {"datetime": v.isoformat()}
+            elif isinstance(v, time):
+                v = {"time": v.isoformat()}
+            elif isinstance(v, date):
+                v = {"date": v.isoformat()}
+            doc[k] = v
+
+    if serialize:
+        return json.dumps(doc)
+    return doc
+
+def from_json(doc, instance, includes=None, excludes=None):
+
+    if isinstance(doc, basestring):
+        doc = json.loads(doc)
+
+    if not isinstance(doc, dict):
+        raise TypeError(doc, "doc must be a dict")
+
+    includes = set([includes] if isinstance(includes, basestring) else includes and list(includes) or [])
+    excludes = set([excludes] if isinstance(excludes, basestring) else excludes and list(excludes) or [])
+    attrs = set(instance.__table__.c.keys())
+    attrs = includes | attrs - excludes
+
+    for k, v in doc.iteritems():
+
+        if k in attrs:
+            if isinstance(v, dict):
+                if "date" in v:
+                    v = dateutil.parser.parse(v["date"])
+                elif "time" in v:
+                    v = dateutil.parser.parse(v["time"])
+                elif "datetime" in v:
+                    v = dateutil.parser.parse(v["datetime"])
+
+            setattr(instance, k, v)
+        else:
+            raise HTTPError(400)
+
+    return instance
+
+

tests/test.cfg.sample

+[sqlalchemy_engine]
+sqlalchemy.url = postgresql+psycopg2://tester:tester@localhost/test_xantana
+sqlalchemy.pool_recycle = 3600
+sqlalchemy.echo = False

tests/test_rest_api.py

+try:
+    import simplejson as json
+except ImportError:
+    import json
+
+import logging
+
+import cherrypy
+
+from datetime import date, time, datetime
+
+from cherrypy.test.helper import CPWebCase
+from sqlalchemy import engine_from_config
+from sqlalchemy.orm import sessionmaker
+from testconfig import config as testconfig
+
+from pythonhk.cp.plugins import *
+from pythonhk.models import User
+from pythonhk.models import metadata
+from pythonhk.rest_api import make_rest_api_app
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_test_engine(section_name='sqlalchemy_engine'):
+    return engine_from_config(dict([(str(k), v) for k, v in testconfig[section_name].iteritems()]))
+
+def populate_db():
+    global engine
+    engine = get_test_engine()
+    metadata.bind = engine
+    metadata.create_all()
+
+    session = sessionmaker(bind=engine)()
+
+    user = User(fbid="111111",
+                username=u"wyuenho",
+                displayname=u"Jimmy Wong",
+                email=u"wyuenho@gmail.com",
+                password="123456",
+                sex=u"m",
+                date_of_birth=date(1982, 8, 9),
+                bio=u"",
+                locale="zh_HK",
+                timezone=8)
+
+    session.add(user)
+
+    session.commit()
+
+def clean_db():
+    metadata.drop_all()
+
+
+class UserRESTAPITest(CPWebCase):
+
+    @staticmethod
+    def setup_server():
+        cherrypy.engine.sqlalchemy = SQLAlchemyPlugin(cherrypy.engine)
+        cherrypy.engine.sqlalchemy.configure_engines(testconfig)
+        cherrypy.config.update({"engine.sqlalchemy.on": True})
+        make_rest_api_app()
+
+    def setUp(self):
+        populate_db()
+
+    def tearDown(self):
+        clean_db()
+
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.