Repository: passiomatic/coldsweat (language: python)

Issue:
Implement a standalone fetch script
Implement a standalone fetch script to be triggered by cron or similar service.



Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..faee04a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,66 @@
+CSS_FILES=./static/stylesheets/all.scss:./static/stylesheets/all.css
+
+.PHONY: test coverage all watch clean docker-build docker-run save cp-to-server \
+	import-on-server run-on-server disable-on-server
+
+.DEFAULT_GOAL := help
+
+define PRINT_HELP_PYSCRIPT
+import re, sys
+
+for line in sys.stdin:
+	match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
+	if match:
+		target, help = match.groups()
+		print("%-20s %s" % (target, help))
+endef
+export PRINT_HELP_PYSCRIPT
+
+PY ?= python3
+
+help:
+	@$(PY) -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
+test:  ## run the test suite with pytest
+	python -m pytest
+
+coverage:  ## run the tests and collect metrics
+	python -m pytest -vv --cov=coldsweat tests
+
+all: update
+
+update:  ## update css files from sass sources
+	sass -f -t compressed --update $(CSS_FILES)
+
+watch:  ## rebuild css on changes to sass sources
+	sass --watch $(CSS_FILES)
+
+clean:  ## remove sass cache
+	rm -r ./.sass-cache
+
+
+docker-build:  ## build a docker image
+	docker build -t $(ORG)/$(IMG):$(TAG) -f docker/Dockerfile .
+
+docker-run:  ## run the docker image locally
+	docker run -it -p 9001:9001 -v $(CURDIR)/data:/var/lib/coldsweat/db $(ORG)/$(IMG):$(TAG)
+
+save:  ## saves the image to a localfile
+	sudo docker save $(ORG)/$(IMG):$(TAG) > $(ORG).$(IMG).$(TAG).img
+
+
+cp-to-server:  ## upload the image to a server
+	scp $(ORG).$(IMG).$(TAG).img $(SERVER):~/
+
+
+import-on-server:  ## imports the image on the serve
+	ssh -t $(SERVER) "echo $(SUDOPASSWORD) | sudo -S docker load --input  $(ORG).$(IMG).$(TAG).img"
+
+
+run-on-server:  ## runs the docker image on a remote server
+	ssh -t $(SERVER) "echo $(SUDOPASSWORD) | sudo -S docker run --name $(CONTAINERNAME) \
+		-d --restart always
+		-v $(BASEDIR):/var/lib/tinycms/db \
+		-p $(HOST):9001:9001 $(ORG)/$(IMG):$(TAG)"
+
+disable-on-server:  ## stops the container on a remote server
+	ssh -t $(SERVER) "echo $(SUDOPASSWORD) | sudo -S docker rm --force $(CONTAINERNAME)"
diff --git a/coldsweat/__init__.py b/coldsweat/__init__.py
index 51568ce..74d9863 100644
--- a/coldsweat/__init__.py
+++ b/coldsweat/__init__.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 '''
-Coldsweat - RSS aggregator and web reader compatible with the Fever API 
+Coldsweat - RSS aggregator and web reader compatible with the Fever API
 
 Copyright (c) 2013—2016 Andrea Peltrin
 Portions are copyright (c) 2013 Rui Carmo
@@ -13,7 +13,7 @@
 
 import os
 import logging
-from config import *
+from .config import load_config
 
 # Define an informal API for plugin implementations
 
@@ -21,55 +21,69 @@
     'VERSION_STRING',
     'USER_AGENT',
 
-    # Synthesized entries and feed URI's 
+    # Synthesized entries and feed URI's
     'FEED_TAG_URI',
     'ENTRY_TAG_URI',
-    
+
     # Configuration
     'installation_dir',
     'template_dir',
     'plugin_dir',
     'config',
-    
+
     # Logging
     'logger',
 ]
 
-VERSION_STRING  = '%d.%d.%d%s' % __version__
-USER_AGENT      = 'Coldsweat/%s Feed Fetcher <http://lab.passiomatic.com/coldsweat/>' % VERSION_STRING
+VERSION_STRING = '%d.%d.%d%s' % __version__
+USER_AGENT = ('Coldsweat/%s Feed Fetcher <http://lab.passiomatic.com/'
+              'coldsweat/>' % VERSION_STRING)
 
-FEED_TAG_URI    = 'tag:lab.passiomatic.com,2017:coldsweat:feed:%s'         
-ENTRY_TAG_URI   = 'tag:lab.passiomatic.com,2017:coldsweat:entry:%s'
+FEED_TAG_URI = 'tag:lab.passiomatic.com,2017:coldsweat:feed:%s'
+ENTRY_TAG_URI = 'tag:lab.passiomatic.com,2017:coldsweat:entry:%s'
 
-# Figure out installation directory. This has 
+# Figure out installation directory. This has
 #  to work for the fetcher script too
-installation_dir, _ = os.path.split(os.path.dirname(os.path.abspath(__file__))) 
-template_dir        = os.path.join(installation_dir, 'coldsweat/templates')
-plugin_dir          = os.path.join(installation_dir, 'plugins')
+
+installation_dir = os.environ.get("COLDSWEAT_INSTALL_DIR")
+
+if not installation_dir:
+    installation_dir, _ = os.path.split(
+        os.path.dirname(os.path.abspath(__file__)))
+
+template_dir = os.path.join(installation_dir, 'coldsweat/templates')
+plugin_dir = os.path.join(installation_dir, 'plugins')
 
 # ------------------------------------------------------
 # Load up configuration settings
 # ------------------------------------------------------
 
-config = load_config(os.path.join(installation_dir, 'etc/config'))
+config_path = os.environ.get("COLDSWEAT_CONFIG_PATH")
+
+if not config_path:
+    config_path = os.path.join(installation_dir, 'etc/config')
+
+config = load_config(config_path)
 
 # ------------------------------------------------------
 # Configure logger
 # ------------------------------------------------------
 
 # Shared logger instance
+for module in 'peewee', 'requests':
+    logging.getLogger(module).setLevel(logging.WARN)
+
 logger = logging.getLogger()
 
-if config.log.filename:
+if config.log.filename == 'stderr':
+    logger.addHandler(logging.StreamHandler())
+    logger.setLevel(config.log.level)
+elif config.log.filename:
     logging.basicConfig(
-        filename    = config.log.filename,
-        level       = getattr(logging, config.log.level),
-        format      = '[%(asctime)s] %(process)d %(levelname)s %(message)s',
+        filename=config.log.filename,
+        level=getattr(logging, config.log.level),
+        format='[%(asctime)s] %(process)d %(levelname)s %(message)s',
     )
-    for module in 'peewee', 'requests':        
-        logging.getLogger(module).setLevel(logging.WARN)
 else:
-    # Silence is golden 
+    # Silence is golden
     logger.addHandler(logging.NullHandler())
-
-
diff --git a/coldsweat/app.py b/coldsweat/app.py
index 2114e65..9aa09be 100644
--- a/coldsweat/app.py
+++ b/coldsweat/app.py
@@ -6,16 +6,19 @@
 Portions are copyright (c) 2013 Rui Carmo
 License: MIT (see LICENSE for details)
 '''
-import sys, os, re
+import io
+import re
+import sys
+
 from traceback import format_tb
 
 from webob import Request, Response
-from webob.exc import *
+from webob.request import LimitedLengthFile, DisconnectionError
+from webob.exc import HTTPClientError, HTTPNotFound, HTTPRedirection
+from . models import connect, close
 
-from models import connect, close
+from coldsweat import logger
 
-from utilities import *
-from coldsweat import *
 
 __all__ = [
     'GET',
@@ -32,81 +35,138 @@
 
 ROUTES = []
 
-def on(pattern, http_methods):    
-    def wrapper(handler):         
+
+def on(pattern, http_methods):
+    def wrapper(handler):
         route = re.compile(pattern, re.U), http_methods, handler.__name__
         ROUTES.append(route)
-        return handler         
+        return handler
     return wrapper
-        
-def GET(pattern):    
-    return on(pattern, ('GET', ))  
 
-def POST(pattern):    
-    return on(pattern, ('POST', ))  
 
-# Handler for both GET and POST requests
-def form(pattern):    
-    return on(pattern, ('GET', 'POST'))  
+def GET(pattern):
+    return on(pattern, ('GET', ))
+
 
+def POST(pattern):
+    return on(pattern, ('POST', ))
 
 
+# Handler for both GET and POST requests
+def form(pattern):
+    return on(pattern, ('GET', 'POST'))
+
 # ------------------------------------------------------
 # Base WSGI app
 # ------------------------------------------------------
 
-    
+
+def patched_read_into(self, buff):
+    if not self.remaining:
+        return 0
+    sz0 = min(len(buff), self.remaining)
+    data = self.file.read(sz0)
+    sz = len(data)
+    self.remaining -= sz
+    if sz < sz0 and self.remaining:
+        raise DisconnectionError(
+            "The client disconnected while sending the body "
+            "(%d more bytes were expected)" % (self.remaining,)
+        )
+    buff[:sz] = data.encode()
+    return sz
+
+
+LimitedLengthFile.readinto = patched_read_into
+
+
+class PatchedRequest(Request):
+
+    @property
+    def body_file(self):
+        """
+            Input stream of the request (wsgi.input).
+            Setting this property resets the content_length and seekable flag
+            (unlike setting req.body_file_raw).
+        """
+
+        if not self.is_body_readable:
+            return io.BytesIO()
+
+        r = self.body_file_raw
+        clen = self.content_length
+
+        if not self.is_body_seekable and clen is not None:
+            # we need to wrap input in LimitedLengthFile
+            # but we have to cache the instance as well
+            # otherwise this would stop working
+            # (.remaining counter would reset between calls):
+            #   req.body_file.read(100)
+            #   req.body_file.read(100)
+            env = self.environ
+            wrapped, raw = env.get("webob._body_file", (0, 0))
+
+            #  if raw is not r:
+            wrapped = LimitedLengthFile(r, clen)
+            wrapped = io.BufferedReader(wrapped)
+            env["webob._body_file"] = wrapped, r
+            r = wrapped
+
+        return r
+
+
 class WSGIApp(object):
 
     def __call__(self, environ, start_response):
-        
-        request = Request(environ)
-        
+        request = PatchedRequest(environ)
+
         handler, args = self._find_handler(request)
         if not handler:
-            raise HTTPNotFound('No handler defined for %s (%s)' % (request.path_info, request.method))  
+            raise HTTPNotFound(
+                'No handler defined for %s (%s)' % (
+                    request.path_info, request.method))
         if not args:
-            args = ()        
-                            
+            args = ()
+
         # Save request object for handlers
-        self.request            = request
-        self.application_url    = request.application_url
-        
+        self.request = request
+        self.application_url = request.application_url
+
         connect()
         response = handler(*args)
         if not response:
-            response = Response() # Provide an empty response
+            response = Response()  # Provide an empty response
         close()
 
         return response(environ, start_response)
-    
+
     def _find_handler(self, request):
 
         # Sanity check
         try:
             request.path_info
         except KeyError:
-            request.path_info = '' #@@TODO add / ? 
+            request.path_info = ''  # @@TODO add / ?
 
         for pattern, http_methods, name in ROUTES:
-            match = pattern.match(request.path_info)        
-            if match and request.method in http_methods:                    
+            match = pattern.match(request.path_info)
+            if match and request.method in http_methods:
                 try:
                     return getattr(self, name), match.groups()
                 except AttributeError:
-                    break # path_info matches, but app does not
+                    break  # path_info matches, but app does not
 
         # No match found
         return None, None
 
- 
 # ------------------------------------------------------
 # Exception middleware
 # ------------------------------------------------------
 
+
 class ExceptionMiddleware(object):
     '''
-    WSGI middleware which sends out an exception traceback 
+    WSGI middleware which sends out an exception traceback
       if something goes wrong. See: http://bit.ly/hQd5b1
     '''
     def __init__(self, app):
@@ -116,40 +176,40 @@ def __call__(self, environ, start_response):
         """
         Call the application and catch exceptions.
         """
-        app_iter = None
 
+        app_iter = None
         # Just call the application and send the output back
         #   unchanged and catch relevant HTTP exceptions
         try:
             app_iter = self.app(environ, start_response)
-        except (HTTPClientError, HTTPRedirection), exc:        
-            app_iter = exc(environ, start_response)            
+        except (HTTPClientError, HTTPRedirection) as exc:
+            app_iter = exc(environ, start_response)
         # If an exception occours we get the exception information
         #   and prepare a traceback we can render
-        except Exception:        
+        except Exception:
             exc_type, exc_value, tb = sys.exc_info()
             traceback = ['Traceback (most recent call last):']
             traceback += format_tb(tb)
             traceback.append('%s: %s' % (exc_type.__name__, exc_value))
-            # We might have not a stated response by now. Try to  
+            # We might have not a stated response by now. Try to
             #   start one with the status code 500 or ignore any
             #   raised exception if the application already
-            #   started one            
+            #   started one
             try:
                 start_response('500 Internal Server Error', [
                                ('Content-Type', 'text/plain')])
             except Exception:
                 pass
-            
-            traceback = '\n'.join(traceback)            
+
+            traceback = '\n'.join(traceback)
             logger.error(traceback)
-                        
+
             yield traceback
-        
+
         for item in app_iter:
             yield item
 
-        # Returned iterable might have a close function. 
+        # Returned iterable might have a close function.
         #   If it exists it *must* be called
         if hasattr(app_iter, 'close'):
             app_iter.close()
@@ -159,7 +219,10 @@ def __call__(self, environ, start_response):
 # Set up WSGI app
 # ------------------------------------------------------
 
-def setup_app():    
+def setup_app():
     # Postpone import to avoid circular dependencies
-    import fever, frontend, cascade
-    return ExceptionMiddleware(cascade.Cascade([fever.setup_app(), frontend.setup_app()]))
+    from coldsweat import fever
+    from coldsweat import frontend
+    from coldsweat import cascade
+    return ExceptionMiddleware(
+        cascade.Cascade([fever.setup_app(), frontend.setup_app()]))
diff --git a/coldsweat/cascade.py b/coldsweat/cascade.py
index 6cea038..bbe64bf 100644
--- a/coldsweat/cascade.py
+++ b/coldsweat/cascade.py
@@ -1,23 +1,53 @@
-# -*- coding: utf-8 -*-
-"""
-Description: Cascades through several applications, 
-  so long as applications return '404 Not Found'
+# (c) 2005 Ian Bicking and contributors;
+# written for Paste (http://pythonpaste.org)
+# Licensed under the MIT licensae:
+# http://www.opensource.org/licenses/mit-license.php
 
-Copyright (c) 2013—2016 Andrea Peltrin
-Portions are copyright (c) 2005 Ian Bicking and contributors
-License: MIT (see LICENSE for details)
 """
-#import tempfile
-from cStringIO import StringIO
+Cascades through several applications, so long as applications
+return ``404 Not Found``.
+"""
+from paste import httpexceptions
+from paste.util import converters
+import tempfile
+from six import BytesIO
+
+
+__all__ = ['Cascade']
+
+
+def make_cascade(loader, global_conf, catch='404', **local_conf):
+    """
+    Entry point for Paste Deploy configuration
+
+    Expects configuration like::
 
-from webob.exc import HTTPException, HTTPNotFound
-#from coldsweat import log
+        [composit:cascade]
+        use = egg:Paste#cascade
+        # all start with 'app' and are sorted alphabetically
+        app1 = foo
+        app2 = bar
+        ...
+        catch = 404 500 ...
+    """
+    catch = map(int, converters.aslist(catch))
+    apps = []
+    for name, value in local_conf.items():
+        if not name.startswith('app'):
+            raise ValueError(
+                "Bad configuration key %r (=%r); all configuration keys "
+                "must start with 'app'"
+                % (name, value))
+        app = loader.get_app(value, global_conf=global_conf)
+        apps.append((name, app))
+    apps.sort()
+    apps = [app for name, app in apps]
+    return Cascade(apps, catch=catch)
 
-CHUNK_SIZE = 4096
-    
-class Cascade(object):
 
-    '''
+class Cascade:
+
+    """
     Passed a list of applications, ``Cascade`` will try each of them
     in turn.  If one returns a status code listed in ``catch`` (by
     default just ``404 Not Found``) then the next application is
@@ -27,76 +57,86 @@ class Cascade(object):
     response is used.
 
     Instances of this class are WSGI applications.
-    '''
+    """
 
-    def __init__(self, applications, catch=(HTTPNotFound,)):
+    def __init__(self, applications, catch=(404,)):
         self.apps = applications
         self.catch_codes = {}
         self.catch_exceptions = []
         for error in catch:
-            if issubclass(error, HTTPException):
+            if isinstance(error, str):
+                error = int(error.split(None, 1)[0])
+            if isinstance(error, httpexceptions.HTTPException):
                 exc = error
                 code = error.code
             else:
-                raise ValueError('%s must be a subclass of webob.exc.HTTPException' % error)
+                exc = httpexceptions.get_exception(error)
+                code = error
             self.catch_codes[code] = exc
             self.catch_exceptions.append(exc)
         self.catch_exceptions = tuple(self.catch_exceptions)
-                
+
     def __call__(self, environ, start_response):
-        '''
+        """
         WSGI application interface
-        '''
+        """
+        failed = []
+
+        def repl_start_response(status, headers, exc_info=None):
+            code = int(status.split(None, 1)[0])
+            if code in self.catch_codes:
+                failed.append(None)
+                return _consuming_writer
+            return start_response(status, headers, exc_info)
 
         try:
             length = int(environ.get('CONTENT_LENGTH', 0) or 0)
         except ValueError:
             length = 0
-
-        # POST/PUT request: copy wsgi.input
         if length > 0:
+            # We have to copy wsgi.input
             copy_wsgi_input = True
-            f = environ['wsgi.input']
-            if length < 0:
-                data = f.read()
+            if length > 4096 or length < 0:
+                f = tempfile.TemporaryFile()
+                if length < 0:
+                    f.write(environ['wsgi.input'].read())
+                else:
+                    copy_len = length
+                    while copy_len > 0:
+                        chunk = environ['wsgi.input'].read(min(copy_len, 4096))
+                        if not chunk:
+                            raise IOError("Request body truncated")
+                        f.write(chunk)
+                        copy_len -= len(chunk)
+                f.seek(0)
             else:
-                data = f.read(length)
-            environ['wsgi.input'] = StringIO(data)
-        else:
-            copy_wsgi_input = False
-
-        def repl_start_response(status, headers, exc_info=None):
-            code = status.split(None, 1)[0]
-            if int(code) in self.catch_codes:
-                failed.append(None)
-                return _consuming_writer
-            return start_response(status, headers, exc_info)
+                f = BytesIO(environ['wsgi.input'].read(length))
 
-        apps = list(self.apps)
-        last_app = apps.pop()
-
-        for app in apps:
+            environ['wsgi.input'] = f
 
+        else:
+            copy_wsgi_input = False
+        for app in self.apps[:-1]:
             environ_copy = environ.copy()
             if copy_wsgi_input:
                 environ_copy['wsgi.input'].seek(0)
             failed = []
             try:
-                app_iter = app(environ_copy, repl_start_response)
+                v = app(environ_copy, repl_start_response)
                 if not failed:
-                    return app_iter
+                    return v
                 else:
-                    if hasattr(app_iter, 'close'):
-                        # Exhaust the iterator first, then close
-                        tuple(app_iter)
-                        app_iter.close()
-            except self.catch_exceptions, exc:
+                    if hasattr(v, 'close'):
+                        # Exhaust the iterator first:
+                        list(v)
+                        # then close:
+                        v.close()
+            except self.catch_exceptions:
                 pass
-
-        # Try last app
         if copy_wsgi_input:
             environ['wsgi.input'].seek(0)
-        return last_app(environ, start_response)
+        return self.apps[-1](environ, start_response)
+
 
 def _consuming_writer(s):
     pass
diff --git a/coldsweat/commands.py b/coldsweat/commands.py
index bf05a2c..8ea7f35 100644
--- a/coldsweat/commands.py
+++ b/coldsweat/commands.py
@@ -5,93 +5,114 @@
 Copyright (c) 2013—2016 Andrea Peltrin
 License: MIT (see LICENSE for details)
 """
-import os, sys
+import os
+import sys
 from optparse import OptionParser, make_option
 from getpass import getpass
-import readline
+from datetime import datetime
+
+from wsgiref.simple_server import WSGIServer, WSGIRequestHandler, make_server
 
-from wsgiref.simple_server import *
 from webob.static import DirectoryApp
-from peewee import OperationalError
+from webob import exc
+from webob.dec import wsgify
+from webob.response import Response
+
+from . models import (connect,
+                      close,
+                      User,
+                      setup_database_schema
+                      )
+
+from . controllers import FeedController, UserController
+
+from . import cascade
+from . import fever
 
-from coldsweat import *
-from models import *
-from controllers import *
-from app import *
-from translators import *
+from . import frontend
 
-import cascade, fever, frontend
-from utilities import render_template
-from plugins import trigger_event, load_plugins
-import filters
+from . app import ExceptionMiddleware, ROUTES
+from coldsweat import (installation_dir,
+                       template_dir,
+                       FEED_TAG_URI,
+                       VERSION_STRING)
 
-# Ensure all web server activity is logged on stdout 
+from . utilities import (render_template,
+                         make_sha1_hash,
+                         format_http_datetime)
+from . import filters
+
+# Ensure all web server activity is logged on stdout
 #   and not stderr like default WSGIRequestHandler
+
+
 class WSGIRequestHandler_(WSGIRequestHandler):
     def log_message(self, format, *args):
         sys.stdout.write("%s - - [%s] %s\n" %
                          (self.client_address[0],
                           self.log_date_time_string(),
-                          format%args))
-                          
-                          
+                          format % args))
+
+
 class CommandError(Exception):
     pass
 
+
 class CommandController(FeedController, UserController):
-    
+
     def _get_user(self, username):
         try:
             user = User.get(User.username == username)
         except User.DoesNotExist:
-            raise CommandError('unable to find user %s. Use -u option to specify a different user' % username)
-    
-        return user
+            raise CommandError(
+                'unable to find user %s. Use -u option to '
+                'specify a different user' % username)
 
+        return user
 
-    def run_command(self, name, options, args):    
+    def run_command(self, name, options, args):
         try:
             handler = getattr(self, 'command_%s' % name)
         except AttributeError:
-            raise CommandError('unrecognized command %s, use the -h option for a list of available commands' % name)
+            raise CommandError(
+                'unrecognized command %s, '
+                'use the -h option for a list of available commands' % name)
+
+        handler(options, args)
 
-        handler(options, args)        
-        
     # Import
-     
+
     def command_import(self, options, args):
         '''Imports feeds from OPML file'''
-    
+
         if not args:
             raise CommandError('no input OPML file given')
-    
+
         self.user = self._get_user(options.username)
 
         if options.saved_entries:
             self._import_saved_entries(options, args)
         else:
             self._import_feeds(options, args)
-                
-    def _import_feeds(self, options, args): 
-        
+
+    def _import_feeds(self, options, args):
+
         feeds = self.add_feeds_from_file(args[0])
         for feed, group in feeds:
-            self.add_subscription(feed, group)      
+            self.add_subscription(feed, group)
         if options.fetch_data:
             # Fetch only imported feeds
-            self.fetch_feeds([feed for feed, _ in feeds]) 
-        
-        print "Import%s completed for user %s. See log file for more information" % (' and fetch' if options.fetch_data else '', self.user.username)
+            self.fetch_feeds([feed for feed, _ in feeds])
 
-    def _import_saved_entries(self, options, args):
-        import_entries(args[0])
-    
-        print "Import completed for user %s. See log file for more information" % self.user.username
+        print("Import%s completed for user %s."
+              " See log file for more information"
+              % (' and fetch' if options.fetch_data else '',
+                 self.user.username))
 
     # Export
-    
+
     def command_export(self, options, args):
-    
+
         if not args:
             raise CommandError('no output OPML file given')
 
@@ -100,245 +121,255 @@ def command_export(self, options, args):
             self._export_saved_entries(options, args)
         else:
             self._export_feeds(options, args)
-        
-        print "Export completed for user %s" % self.user.username
-        
+
+        print("Export completed for user %s" % self.user.username)
+
     def _export_feeds(self, options, args):
         '''Exports feeds to OPML file'''
-    
+
         filename = args[0]
-        #@@TODO Use a 'http_datetime' filter in template instead
-        timestamp = format_http_datetime(datetime.utcnow())        
-        groups = [ (group.title, self.get_group_feeds(group)) for group in self.get_groups() ]
-        
+        # @@TODO Use a 'http_datetime' filter in template instead
+        timestamp = format_http_datetime(datetime.utcnow())
+        groups = [(group.title, self.get_group_feeds(group))
+                  for group in self.get_groups()]
+
         with open(filename, 'w') as f:
-            f.write(render_template(os.path.join(template_dir, 'export.xml'), locals()))
+            f.write(render_template(os.path.join(template_dir,
+                                                 'export.xml'), locals()))
 
     def _export_saved_entries(self, options, args):
         '''Exports saved entries to Atom file'''
-        
+
         if not args:
             raise CommandError('no output Atom file given')
-        
-        filename = args[0]    
-        timestamp = datetime.utcnow()        
+
+        filename = args[0]
+        timestamp = datetime.utcnow()
         q = self.get_saved_entries()
-        guid = FEED_TAG_URI % make_sha1_hash(self.user.email or self.user.username)
+        guid = FEED_TAG_URI % make_sha1_hash(
+            self.user.email or self.user.username)
         version = VERSION_STRING
 
         with open(filename, 'w') as f:
-            f.write(render_template(os.path.join(template_dir, 'export-saved.xml'), locals(), filters))
+            f.write(render_template(os.path.join(template_dir,
+                                                 'export-saved.xml'),
+                                    locals(), filters))
 
-    # Fetch            
+    # Fetch
 
     def command_fetch(self, options, args):
         '''Starts a feeds refresh procedure'''
-    
+
         self.fetch_all_feeds()
-        print 'Fetch completed. See log file for more information'
-    
-    command_refresh = command_fetch # Alias
+        print('Fetch completed. See log file for more information')
+
+    command_refresh = command_fetch  # Alias
 
     # Local server
 
     def command_serve(self, options, args):
         '''Starts a local server'''
-    
-        static_app = DirectoryApp(os.path.join(installation_dir, "static"), index_page=None)
-        
-        # Create a cascade that looks for static files first, 
+
+        for route in ROUTES:
+            print(route[0].pattern, route[1], route[2])
+
+        static_app = CustomDirectoryApp(
+            os.path.join(installation_dir, "static"),
+            index_page=None)
+
+        # Create a cascade that looks for static files first,
         #  then tries the other apps
-        cascade_app = ExceptionMiddleware(cascade.Cascade([static_app, fever.setup_app(), frontend.setup_app()]))
-        
-        address = '0.0.0.0' if options.allow_remote_access else 'localhost'        
-        httpd = make_server(address, options.port, cascade_app,  WSGIServer, WSGIRequestHandler_)
-        print 'Serving on http://%s:%s' % (address, options.port)
+        cascade_app = ExceptionMiddleware(
+            cascade.Cascade(
+                [static_app, fever.setup_app(), frontend.setup_app()]))
+
+        address = '0.0.0.0' if options.allow_remote_access else 'localhost'
+        httpd = make_server(address,
+                            options.port,
+                            cascade_app,
+                            WSGIServer,
+                            WSGIRequestHandler_)
+        print('Serving on http://%s:%s' % (address, options.port))
         try:
             httpd.serve_forever()
         except KeyboardInterrupt:
-            print 'Interrupted by user'            
-    
+            print('Interrupted by user')
+
     # Setup and upgrade
- 
+
     def command_setup(self, options, args):
         '''Setup a working database'''
-        username = options.username        
-            
+        username = options.username
+
         setup_database_schema()
 
         def get_password(label):
-          while True:
-              password = read_password(label)
-              if not User.validate_password(password):
-                  print 'Error: password should be at least %d characters long' % User.MIN_PASSWORD_LENGTH
-                  continue        
-              password_again = read_password("Enter password (again): ")
-              
-              if password != password_again:
-                  print "Error: passwords do not match, please try again"
-              else:
-                  return password
-                            
+
+            while True:
+                password = read_password(label)
+                if not User.validate_password(password):
+                    print(
+                        'Error: password should be at least %d characters long'
+                        % User.MIN_PASSWORD_LENGTH)
+                    continue
+                password_again = read_password("Enter password (again): ")
+
+                if password != password_again:
+                    print("Error: passwords do not match, please try again")
+                else:
+                    return password
 
         # Just reset user password
         if options.reset_password:
-          try:
-              user = User.get(User.username == username)
-          except User.DoesNotExist:
-              raise CommandError('unknown user %s, please select another username with the -u option' % username)
+            try:
+                user = User.get(User.username == username)
+            except User.DoesNotExist:
+                raise CommandError(
+                    'unknown user %s, please select another username '
+                    'with the -u option' % username)
+
+            password = get_password("Reset password for user %s: " % username)
+            user.password = password
+            user.save()
 
-          password = get_password("Reset password for user %s: " % username)
-          user.password = password
-          user.save()
+            return
 
-          return 
-    
         # Regular setup process. Check if username is already in use
         try:
             User.get(User.username == username)
-            raise CommandError('user %s already exists, please select another username with the -u option' % username)
+            raise CommandError('user %s already exists, please select '
+                               'another username with the -u option'
+                               % username)
         except User.DoesNotExist:
             pass
-    
-        email = raw_input('Enter e-mail for user %s (needed for Fever sync, hit enter to leave blank): ' % username)
+
+        email = input(
+            'Enter e-mail for user %s (needed for Fever sync, '
+            'hit enter to leave blank): ' % username)
         password = get_password("Enter password for user %s: " % username)
-    
-        User.create(username=username, email=email, password=password)
-        print "Setup completed for user %s." % username
 
-    def command_upgrade(self, options, args):
-        '''Upgrades Coldsweat internals from a previous version'''
-        
-        try:
-            if migrate_database_schema():
-                print 'Upgrade completed.'
-            else:
-                print 'Database is already up-to-date.'
-        except OperationalError, ex:         
-            logger.error(u'caught exception updating database schema: (%s)' % ex)
-            print  'Error while running database update. See log file for more information.'
+        User.create(username=username, email=email, password=password)
+        print("Setup completed for user %s." % username)
 
-    command_update = command_upgrade # Alias
 
 # --------------------
 # Utility functions
 # --------------------
 
+
 def read_password(prompt_label="Enter password: "):
     if sys.stdin.isatty():
         password = getpass(prompt_label)
     else:
         # Make scriptable by reading password from stdin
-        print prompt_label
+        print(prompt_label)
         password = sys.stdin.readline().rstrip()
 
     return password
 
-def import_entries(filename):
-
-  soup = feedparser.parse(filename)         
-  # Got parsing error?
-  if hasattr(soup, 'bozo') and soup.bozo:
-      logger.debug(u"%s caused a parser error (%s), tried to parse it anyway" % (filename, soup.bozo_exception))
-  
-  ft = FeedTranslator(soup.feed)
-  
-#   self.feed.last_updated_on    = ft.get_timestamp(self.instant)        
-#   self.feed.alternate_link     = ft.get_alternate_link()        
-#   self.feed.title              = self.feed.title or ft.get_title() # Do not set again if already set
-  
-  feed_author = ft.get_author()
-  
-  for entry_dict in soup.entries:
-  
-      t = EntryTranslator(entry_dict)
-      
-      link    = t.get_link()
-      guid    = t.get_guid(default=link)
-      source  = t.get_source()
-  
-      if not guid:
-          logger.warn(u'could not find GUID for entry from %s, skipped' % filename)
-          continue
-  
-      timestamp               = t.get_timestamp(self.instant)
-      content_type, content   = t.get_content(('text/plain', ''))
-
-      #@@TODO feed = ...
-  
-      try:
-          # If entry is already in database with same hashed GUID, skip it
-          Entry.get(guid_hash=make_sha1_hash(guid)) 
-          logger.debug(u"duplicated entry %s, skipped" % guid)
-          continue
-      except Entry.DoesNotExist:
-          pass
-  
-      entry = Entry(
-          feed              = feed,                
-          guid              = guid,
-          link              = link,
-          title             = t.get_title(default='Untitled'),
-          author            = t.get_author() or feed_author,
-          content           = content,
-          content_type      = content_type,
-          last_updated_on   = timestamp
-      )
-      
-      # At this point we are pretty sure we doesn't have the entry 
-      #  already in the database so alert plugins and save data
-      #trigger_event('entry_parsed', entry, entry_dict)
-      entry.save()
-  
-      logger.debug(u"parsed entry %s from %s" % (guid, filename))  
-
 
 # --------------------
 # Entry point
 # --------------------
-    
-COMMANDS = 'import export serve setup upgrade fetch'.split()    
+
+COMMANDS = 'import export serve setup upgrade fetch'.split()
+
 
 def run():
 
     epilog = "Available commands are: %s" % ', '.join(sorted(COMMANDS))
-    usage='%prog command [options] [args]'
+    usage = '%prog command [options] [args]'
 
     available_options = [
         make_option('-s', '--saved-entries',
-            dest='saved_entries', action='store_true', help='export or import saved entries'),
+                    dest='saved_entries',
+                    action='store_true',
+                    help='export or import saved entries'),
 
-        make_option('-u', '--username', 
-            dest='username', default=User.DEFAULT_USERNAME, help="specifies a username (default is %s)" % User.DEFAULT_USERNAME),
+        make_option('-u', '--username',
+                    dest='username',
+                    default=User.DEFAULT_USERNAME,
+                    help=("specifies a username (default is %s)" %
+                          User.DEFAULT_USERNAME)),
 
         make_option('-w', '--password',
-            dest='reset_password', action='store_true', help='reset a user password'),
+                    dest='reset_password',
+                    action='store_true',
+                    help='reset a user password'),
 
         make_option('-f', '--fetch',
-            dest='fetch_data', action='store_true', help='fetches each feed data after import'),
+                    dest='fetch_data',
+                    action='store_true',
+                    help='fetches each feed data after import'),
+
+        make_option('-p', '--port', default='8080',
+                    dest='port',
+                    type='int',
+                    help='specifies the port to serve on (default 8080)'),
+
+        make_option('-r', '--allow-remote-access',
+                    action='store_true',
+                    dest='allow_remote_access',
+                    help='binds to 0.0.0.0 instead of localhost'),
+    ]
 
-        make_option('-p', '--port', default='8080', 
-            dest='port', type='int', help='specifies the port to serve on (default 8080)'),
+    parser = OptionParser(option_list=available_options,
+                          usage=usage, epilog=epilog)
 
-        make_option('-r', '--allow-remote-access', action='store_true', dest='allow_remote_access', help='binds to 0.0.0.0 instead of localhost'),
-    ]
-        
-    parser = OptionParser(option_list=available_options, usage=usage, epilog=epilog)
-     
     command_options, args = parser.parse_args()
+
     if not args:
-        parser.error('no command given, use the -h option for a list of available commands')
-        
+        parser.error(
+            'no command given, '
+            'use the -h option for a list of available commands')
+
     command_name, command_args = args[0].lower(), args[1:]
 
     connect()
+
     cc = CommandController()
     try:
         cc.run_command(command_name, command_options, command_args)
-    except CommandError, ex:
+    except CommandError as ex:
         parser.error(ex)
     finally:
         close()
         # Flush and close all logging handlers
         import logging
         logging.shutdown()
+
+
+class CustomDirectoryApp(DirectoryApp):
+
+    """
+    Fixes a weired bug in the latest WebOb (1.8.5) version
+    """
+
+    @wsgify
+    def __call__(self, req):
+        req_path = req.path_info.lstrip('/')
+        if sys.version_info.major < 3:
+            req_path = req_path.encode()
+        path = os.path.abspath(os.path.join(
+            self.path, req_path))
+
+        # this is needed to work around a bug in WebOb
+        if os.path.isdir(path):
+            path += os.path.sep
+        if os.path.isdir(path) and self.index_page:
+            return self.index(req, path)
+        if (self.index_page and self.hide_index_with_redirect
+                and path.endswith(os.path.sep + self.index_page)):
+            new_url = req.path_url.rsplit('/', 1)[0]
+            new_url += '/'
+            if req.query_string:
+                new_url += '?' + req.query_string
+            return Response(
+                status=301,
+                location=new_url)
+        if not path.startswith(self.path):
+            return exc.HTTPForbidden()
+        elif not os.path.isfile(path):
+            return exc.HTTPNotFound(comment=path)
+        else:
+            return self.make_fileapp(path)
diff --git a/coldsweat/config.py b/coldsweat/config.py
index a13490c..e713423 100644
--- a/coldsweat/config.py
+++ b/coldsweat/config.py
@@ -8,29 +8,28 @@
 '''
 
 import os
-from ConfigParser import SafeConfigParser
 
-from utilities import Struct
+from configparser import ConfigParser as SafeConfigParser
+
+from . utilities import Struct
 
 __all__ = [
     'load_config',
 ]
 
 DEFAULTS = {
-    'min_interval'      : '900',
-    'max_errors'        : '50',
-    'max_history'       : '7',
-    'timeout'           : '10',
-    'processes'         : '4',
-    
-    'level'             : 'INFO',
-    'filename'          : '',       # Don't log
-    
-    'static_url'        : '',
-    
-    'load'              : ''
+    'min_interval': '900',
+    'max_errors': '50',
+    'max_history': '7',
+    'timeout': '10',
+    'processes': '4',
+    'level': 'INFO',
+    'filename': '',       # Don't log
+    'static_url': '',
+    'load': ''
 }
 
+
 def load_config(config_path):
     '''
     Load up configuration settings
@@ -38,25 +37,26 @@ def load_config(config_path):
     parser = SafeConfigParser(DEFAULTS)
 
     converters = {
-        'min_interval'  : parser.getint,
-        'max_errors'    : parser.getint,
-        'max_history'   : parser.getint,
-        'timeout'       : parser.getint,
-        'processes'     : parser.getint,
+        'min_interval': parser.getint,
+        'max_errors': parser.getint,
+        'max_history': parser.getint,
+        'timeout': parser.getint,
+        'processes': parser.getint,
     }
 
     if os.path.exists(config_path):
         parser.read(config_path)
     else:
-        raise RuntimeError('Could not find configuration file %s' % config_path)
+        raise RuntimeError(
+            'Could not find configuration file %s' % config_path)
+
+    config = Struct()
 
-    config = Struct()    
-    
     for section in parser.sections():
-        d = { k : 
-            converters[k](section, k) if k in converters else v 
-            for k, v in parser.items(section)
-        }    
+        d = {k:
+             converters[k](section, k) if k in converters else v
+             for k, v in parser.items(section)
+             }
         config[section] = Struct(d)
-        
-    return config
\ No newline at end of file
+
+    return config
diff --git a/coldsweat/controllers.py b/coldsweat/controllers.py
index ac7f39b..e20f68e 100644
--- a/coldsweat/controllers.py
+++ b/coldsweat/controllers.py
@@ -7,63 +7,67 @@
 License: MIT (see LICENSE for details)
 '''
 
-import sys, os, re, time, urlparse
-from datetime import datetime
+import time
+
 from xml.etree import ElementTree
 
-from peewee import JOIN_LEFT_OUTER, fn, IntegrityError
-import feedparser
-import requests
-from requests.exceptions import *
-from webob.exc import *
+from peewee import JOIN, fn, IntegrityError
 
-from models import *
-from utilities import *
-from plugins import trigger_event, load_plugins
-from filters import escape_html, status_title
-from coldsweat import *
-from fetcher import *
+from .utilities import make_sha1_hash, scrub_url
+from .plugins import trigger_event, load_plugins
+from coldsweat import config, logger
+from coldsweat.models import (connect, Entry, Feed, Group, Read, Saved,
+                              Subscription)
+from .fetcher import Fetcher
 
 
 class UserController(object):
     '''
-    Base user controller class. Derived classes may need to override the user property
+    Base user controller class. Derived classes may need to override the user
+    property
     '''
-    
+
     @property
     def user(self):
         return self._current_user
-    
+
     @user.setter
     def user(self, user):
-        self._current_user = user    
-        
+        self._current_user = user
+
     def add_subscription(self, feed, group):
         '''
         Associate a feed/group pair to current user
         '''
         try:
-            subscription = Subscription.create(user=self.user, feed=feed, group=group)
+            subscription = Subscription.create(user=self.user,
+                                               feed=feed,
+                                               group=group)
         except IntegrityError:
-            logger.debug(u'user %s already has feed %s in her subscriptions' % (self.user.username, feed.self_link))    
+            logger.debug(
+                u'user %s already has feed %s in her subscriptions' % (
+                    self.user.username, feed.self_link))
             return None
-    
-        logger.debug(u'subscribed user %s to feed %s' % (self.user.username, feed.self_link))                
-        return subscription    
+
+        logger.debug(
+            u'subscribed user %s to feed %s' % (
+                self.user.username, feed.self_link))
+        return subscription
 
     def remove_subscription(self, feed):
         '''
         Remove a feed subscription for current user
         '''
-        Subscription.delete().where((Subscription.user == self.user) & (Subscription.feed == feed)).execute()
-
+        Subscription.delete().where(
+            (Subscription.user == self.user) & (
+                Subscription.feed == feed)).execute()
 
     # ------------------------------------------------------
     # Queries
-    # ------------------------------------------------------ 
+    # ------------------------------------------------------
 
     # Entries
-            
+
     def mark_entry(self, entry, status):
         '''
         Mark an entry as read|unread|saved|unsaved for current user
@@ -72,12 +76,15 @@ def mark_entry(self, entry, status):
             try:
                 Read.create(user=self.user, entry=entry)
             except IntegrityError:
-                logger.debug(u'entry %s already marked as read, ignored' % entry.id)
+                logger.debug(u'entry %s already marked as read, ignored' %
+                             entry.id)
                 return
         elif status == 'unread':
-            count = Read.delete().where((Read.user==self.user) & (Read.entry==entry)).execute()
+            count = Read.delete().where((Read.user == self.user) & (
+                Read.entry == entry)).execute()
             if not count:
-                logger.debug(u'entry %s never marked as read, ignored' % entry.id)
+                logger.debug(
+                    u'entry %s never marked as read, ignored' % entry.id)
                 return
         elif status == 'saved':
             try:
@@ -86,72 +93,86 @@ def mark_entry(self, entry, status):
                 logger.debug(u'entry %s already saved, ignored' % entry.id)
                 return
         elif status == 'unsaved':
-            count = Saved.delete().where((Saved.user==self.user) & (Saved.entry==entry)).execute()
+            count = Saved.delete().where((
+                Saved.user == self.user) & (
+                    Saved.entry == entry)).execute()
             if not count:
                 logger.debug(u'entry %s never saved, ignored' % entry.id)
                 return
-        
+
         logger.debug(u'entry %s %s' % (entry.id, status))
-     
-    def get_unread_entries(self, *select):         
-        #@@TODO: include saved information too
+
+    def get_unread_entries(self, *select):
+        # @@TODO: include saved information too
         q = _q(*select).where((Subscription.user == self.user) &
-            ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user))).distinct()
+                              ~(Entry.id << Read.select(Read.entry).where(
+                                Read.user == self.user))).distinct()
         return q
-    
-    def get_saved_entries(self, *select):   
-        #@@TODO: include read information too
-        q = _q(*select).where((Subscription.user == self.user) & 
-            (Entry.id << Saved.select(Saved.entry).where(Saved.user == self.user))).distinct()
+
+    def get_saved_entries(self, *select):
+        # @@TODO: include read information too
+        q = _q(*select).where((Subscription.user == self.user) &
+                              (Entry.id << Saved.select(Saved.entry).where(
+                               Saved.user == self.user))).distinct()
         return q
-    
-    def get_all_entries(self, *select):     
-        #@@TODO: include read and saved information too
+
+    def get_all_entries(self, *select):
+        # @@TODO: include read and saved information too
         q = _q(*select).where(Subscription.user == self.user).distinct()
-        return q    
-    
-    def get_group_entries(self, group, *select):     
-        #@@TODO: include read and saved information too
-        q = _q(*select).where((Subscription.user == self.user) & (Subscription.group == group))
         return q
-        
-    def get_feed_entries(self, feed, *select):     
-        #@@TODO: include read and saved information too
-        q = _q(*select).where((Subscription.user == self.user) & (Subscription.feed == feed)).distinct()
+
+    def get_group_entries(self, group, *select):
+        # @@TODO: include read and saved information too
+        q = _q(*select).where(
+                (Subscription.user == self.user) &
+                (Subscription.group == group))
+        return q
+
+    def get_feed_entries(self, feed, *select):
+        # @@TODO: include read and saved information too
+        q = _q(*select).where(
+            (Subscription.user == self.user) &
+            (Subscription.feed == feed)).distinct()
         return q
 
     # Feeds
-    
-    def get_feeds(self, *select):  
+
+    def get_feeds(self, *select):
         select = select or [Feed, fn.Count(Entry.id).alias('entry_count')]
-        q = Feed.select(*select).join(Entry, JOIN_LEFT_OUTER).switch(Feed).join(Subscription).where(Subscription.user == self.user).group_by(Feed)
-        return q  
+        q = Feed.select(*select).join(
+                Entry, JOIN.LEFT_OUTER).switch(
+                        Feed).join(Subscription).where(
+                                Subscription.user == self.user).group_by(Feed)
+        return q
 
     def get_group_feeds(self, group):
-        q = Feed.select().join(Subscription).where((Subscription.user == self.user) & (Subscription.group == group))
+        q = Feed.select().join(
+                Subscription).where((
+                    Subscription.user == self.user) &
+                    (Subscription.group == group))
         return q
-    
     # Groups
-    
-    def get_groups(self):     
-        q = Group.select().join(Subscription).where(Subscription.user == self.user).distinct().order_by(Group.title) 
-        return q   
+
+    def get_groups(self):
+        q = Group.select().join(
+            Subscription).where(
+                Subscription.user == self.user).distinct().order_by(
+                    Group.title)
+        return q
 
 
- # Shortcut
+# Shortcut
 def _q(*select):
     select = select or (Entry, Feed)
     q = Entry.select(*select).join(Feed).join(Subscription)
-    return q     
-
-
+    return q
 
 
 class FeedController(object):
     '''
     Feed controller class
     '''
-    
+
     def add_feed_from_url(self, self_link, fetch_data=False):
         '''
         Save a new feed object to database via its URL
@@ -159,7 +180,6 @@ def add_feed_from_url(self, self_link, fetch_data=False):
         feed = Feed(self_link=self_link)
         return self.add_feed(feed, fetch_data)
 
-
     def add_feed(self, feed, fetch_data=False):
         '''
         Save a new feed object to database
@@ -167,100 +187,102 @@ def add_feed(self, feed, fetch_data=False):
         feed.self_link = scrub_url(feed.self_link)
 
         try:
-            previous_feed = Feed.get(self_link_hash=make_sha1_hash(feed.self_link))
-            logger.debug(u'feed %s has been already added to database, skipped' % feed.self_link)
+            previous_feed = Feed.get(
+                self_link_hash=make_sha1_hash(feed.self_link))
+            logger.debug(
+                u'feed %s has been already added to database, skipped'
+                % feed.self_link)
             return previous_feed
         except Feed.DoesNotExist:
             pass
 
-        feed.save()        
+        feed.save()
         if fetch_data:
             self.fetch_feeds([feed])
         return feed
 
-#     #@@TODO:  delete feed if there are no subscribers
+#     # @@TODO:  delete feed if there are no subscribers
 #     def remove_feed(self, feed):
 #         pass
 
-
     def add_feeds_from_file(self, filename, fetch_data=False):
         """
-        Add feeds to database reading from a file containing OPML data. 
-        """    
-    
-        # Map OPML attr keys to Feed model 
+        Add feeds to database reading from a file containing OPML data.
+        """
+
+        # Map OPML attr keys to Feed model
         feed_allowed_attribs = {
-            'xmlUrl': 'self_link', 
-            'htmlUrl': 'alternate_link', 
+            'xmlUrl': 'self_link',
+            'htmlUrl': 'alternate_link',
             'title': 'title',
-            'text': 'title', # Alias for title
+            'text': 'title',  # Alias for title
         }
-        
-        # Map OPML attr keys to Group model 
+
+        # Map OPML attr keys to Group model
         group_allowed_attribs = {
             'title': 'title',
-            'text': 'title', # Alias for title
+            'text': 'title',  # Alias for title
         }
-    
-        default_group = Group.get(Group.title == Group.DEFAULT_GROUP)    
-    
-        feeds = []    
+
+        default_group = Group.get(Group.title == Group.DEFAULT_GROUP)
+
+        feeds = []
         groups = [default_group]
-    
-    
-        for event, element in ElementTree.iterparse(filename, events=('start','end')):
+
+        for event, element in ElementTree.iterparse(filename,
+                                                    events=('start', 'end')):
             if event == 'start':
-                 if (element.tag == 'outline') and ('xmlUrl' not in element.attrib):
+                if (element.tag == 'outline') and (
+                        'xmlUrl' not in element.attrib):
                     # Entering a group
                     group = Group()
-    
+
                     for k, v in element.attrib.items():
                         if k in group_allowed_attribs:
                             setattr(group, group_allowed_attribs[k], v)
-    
+
                     try:
-                        group = Group.get(Group.title==group.title)
+                        group = Group.get(Group.title == group.title)
                     except Group.DoesNotExist:
                         group.save()
-                        logger.debug(u'added group %s to database' % group.title)
-    
+                        logger.debug(
+                            u'added group %s to database' % group.title)
+
                     groups.append(group)
-    
+
             elif event == 'end':
                 if (element.tag == 'outline') and ('xmlUrl' in element.attrib):
-    
+
                     # Leaving a feed
                     feed = Feed()
-    
+
                     for k, v in element.attrib.items():
                         if k in feed_allowed_attribs:
                             setattr(feed, feed_allowed_attribs[k], v)
-    
-                    
-                    feed = self.add_feed(feed, fetch_data)  
+
+                    feed = self.add_feed(feed, fetch_data)
                     feeds.append((feed, groups[-1]))
                 elif element.tag == 'outline':
                     # Leaving a group
                     groups.pop()
         return feeds
-        
 
     # ------------------------------------------------------
     # Fetching
-    # ------------------------------------------------------  
+    # ------------------------------------------------------
 
     def fetch_feeds(self, feeds):
         """
         Fetch given feeds, possibly parallelizing requests
         """
-        
+
         start = time.time()
-        
+
         load_plugins()
-    
+
         logger.debug(u"starting fetcher")
         trigger_event('fetch_started')
-            
+
         if config.fetcher.processes:
             from multiprocessing import Pool
             # Each worker has its own connection
@@ -272,33 +294,27 @@ def fetch_feeds(self, feeds):
             # Just sequence requests in this process
             for feed in feeds:
                 feed_worker(feed)
-        
+
         trigger_event('fetch_done', feeds)
-        
-        logger.info(u"%d feeds checked in %.2fs" % (len(feeds), time.time() - start))        
-        
+
+        logger.info(u"%d feeds checked in %.2fs" % (
+            len(feeds), time.time() - start))
 
     def fetch_all_feeds(self):
         """
         Fetch all enabled feeds, possibly parallelizing requests
         """
-    
-        q = Feed.select().where(Feed.is_enabled==True)
-        
+
+        q = Feed.select().where(Feed.is_enabled==True)  # noqa
+
         feeds = list(q)
         if not feeds:
             logger.debug(u"no feeds found to fetch, halted")
             return
-    
+
         self.fetch_feeds(feeds)
 
 
 def feed_worker(feed):
     fetcher = Fetcher(feed)
     fetcher.update_feed()
-
-        
-        
-
-
-
diff --git a/coldsweat/fetcher.py b/coldsweat/fetcher.py
index 4150a5e..a3eb358 100644
--- a/coldsweat/fetcher.py
+++ b/coldsweat/fetcher.py
@@ -7,90 +7,104 @@
 License: MIT (see LICENSE for details)
 '''
 
-import sys, os, re, time, urlparse
+import os
+import urllib.parse as urlparse
+
 from datetime import datetime
 
-from peewee import IntegrityError
 import feedparser
 import requests
-from requests.exceptions import *
-from webob.exc import *
-
-from coldsweat import *
-
-from plugins import trigger_event
-
-from models import *
-from utilities import *
-from translators import *
-
-import markup
-import filters
+from requests.exceptions import RequestException
+from webob.exc import (
+                       HTTPGone,
+                       HTTPNotModified,
+                       HTTPServiceUnavailable,
+                       HTTPNotFound,
+                       HTTPInternalServerError,
+                       HTTPForbidden,
+                       HTTPError)
+from webob.exc import status_map
+from coldsweat import (config, logger, template_dir, USER_AGENT, ENTRY_TAG_URI)
+
+from .plugins import trigger_event
+
+from .models import (Entry, Feed)
+from .translators import EntryTranslator, FeedTranslator
+from .utilities import (datetime_as_epoch,
+                        format_http_datetime,
+                        make_sha1_hash,
+                        make_data_uri,
+                        make_nonce,
+                        render_template,)
+
+from . import filters
 
 __all__ = [
     'Fetcher',
     'fetch_url'
 ]
 
-FETCH_ICONS_DELTA = 30 # Days
+
+FETCH_ICONS_DELTA = 30  # Days
+
 
 class Fetcher(object):
     '''
     Fetch a single given feed
-    '''   
+    '''
 
-    def __init__(self, feed): 
+    def __init__(self, feed):
         # Save timestamp for current fetch operation
         self.instant = datetime.utcnow()
         # Extract netloc
-        _, self.netloc, _, _, _ = urlparse.urlsplit(feed.self_link)
- 
+        _, self.netloc, _, _, _ = urllib.parse.urlsplit(feed.self_link)
+
         self.feed = feed
-    
+
     def handle_500(self, response):
         '''
         Internal server error
         '''
-        self.feed.error_count   += 1        
-        self.feed.last_status   = response.status_code
-        logger.warn(u"%s has caused an error on server, skipped" % self.netloc)        
+        self.feed.error_count += 1
+        self.feed.last_status = response.status_code
+        logger.warn("%s has caused an error on server, skipped" % self.netloc)
         raise HTTPInternalServerError
 
     def handle_403(self, response):
         '''
         Forbidden
         '''
-        self.feed.error_count   += 1        
-        self.feed.last_status   = response.status_code
-        logger.warn(u"%s access was denied, skipped" % self.netloc)        
+        self.feed.error_count += 1
+        self.feed.last_status = response.status_code
+        logger.warn("%s access was denied, skipped" % self.netloc)
         raise HTTPForbidden
-                        
+
     def handle_404(self, response):
         '''
         Not Found
         '''
-        self.feed.error_count   += 1        
-        self.feed.last_status   = response.status_code
-        logger.warn(u"%s has been not found, skipped" % self.netloc)        
+        self.feed.error_count += 1
+        self.feed.last_status = response.status_code
+        logger.warn("%s has been not found, skipped" % self.netloc)
         raise HTTPNotFound
-        
+
     def handle_410(self, response):
         '''
         Gone
         '''
-        self.feed.is_enabled    = False
-        self.feed.error_count   += 1        
-        self.feed.last_status   = response.status_code
-        logger.warn(u"%s is gone, disabled" % self.netloc)
-        self._synthesize_entry('Feed has been removed from the origin server.')        
+        self.feed.is_enabled = False
+        self.feed.error_count += 1
+        self.feed.last_statu = response.status_code
+        logger.warn("%s is gone, disabled" % self.netloc)
+        self._synthesize_entry('Feed has been removed from the origin server.')
         raise HTTPGone
-         
+
     def handle_304(self, response):
         '''
         Not modified
         '''
-        logger.debug(u"%s hasn't been modified, skipped" % self.netloc)               
-        self.feed.last_status = response.status_code        
+        logger.debug("%s hasn't been modified, skipped" % self.netloc)
+        self.feed.last_status = response.status_code
         raise HTTPNotModified
 
     def handle_301(self, response):
@@ -98,65 +112,72 @@ def handle_301(self, response):
         Moved permanently
         '''
         self_link = response.url
-            
+
         try:
             Feed.get(self_link=self_link)
         except Feed.DoesNotExist:
-            self.feed.self_link     = self_link                               
-            self.feed.last_status   = response.status_code
-            logger.info(u"%s has changed its location, updated to %s" % (self.netloc, self_link))
+            self.feed.self_link = self_link
+            self.feed.last_status = response.status_code
+            logger.info(
+                "%s has changed its location, updated to %s" % (
+                    self.netloc, self_link))
         else:
-            self.feed.is_enabled    = False
-            self.feed.last_status   = DuplicatedFeedError.code
-            self.feed.error_count   += 1             
+            self.feed.is_enabled = False
+            self.feed.last_status = DuplicatedFeedError.code
+            self.feed.error_count += 1
             self._synthesize_entry('Feed has a duplicated web address.')
-            logger.warn(u"new %s location %s is duplicated, disabled" % (self.netloc, self_link))                
+            logger.warn(
+                "new %s location %s is duplicated, disabled" % (
+                    self.netloc, self_link))
             raise DuplicatedFeedError
 
     def handle_200(self, response):
         '''
         OK plus redirects
         '''
-        self.feed.etag   = response.headers.get('ETag', None)
+        self.feed.etag = response.headers.get('ETag', None)
         # Save final status code discarding redirects
         self.feed.last_status = response.status_code
 
-    handle_307 = handle_200 # Alias
-    handle_302 = handle_200 # Alias
-
+    handle_307 = handle_200   # Alias
+    handle_302 = handle_200   # Alias
 
     def update_feed(self):
-                
-        logger.debug(u"updating %s" % self.netloc)
-               
+
+        logger.debug("updating %s" % self.netloc)
+
         # Check freshness
         for value in [self.feed.last_checked_on, self.feed.last_updated_on]:
             if not value:
                 continue
-            
+
             # No datetime.timedelta since we need to
             #   deal with large seconds values
-            delta = datetime_as_epoch(self.instant) - datetime_as_epoch(value)    
+            delta = datetime_as_epoch(self.instant) - datetime_as_epoch(value)
             if delta < config.fetcher.min_interval:
-                logger.debug(u"%s is below minimun fetch interval, skipped" % self.netloc)
-                return                                      
-        
+                logger.debug(
+                    "%s is below minimun fetch interval, skipped"
+                    % self.netloc)
+                return
+
         try:
-            response = fetch_url(self.feed.self_link, 
-                timeout=config.fetcher.timeout, 
-                etag=self.feed.etag, 
-                modified_since=self.feed.last_updated_on)
-        except RequestException: 
+            response = fetch_url(self.feed.self_link,
+                                 timeout=config.fetcher.timeout,
+                                 etag=self.feed.etag,
+                                 modified_since=self.feed.last_updated_on)
+        except RequestException:
             # Record any network error as 'Service Unavailable'
-            self.feed.last_status   =  HTTPServiceUnavailable.code
-            self.feed.error_count   += 1   
-            logger.warn(u"a network error occured while fetching %s, skipped" % self.netloc)
+            self.feed.last_status = HTTPServiceUnavailable.code
+            self.feed.error_count += 1
+            logger.warn(
+                "a network error occured while fetching %s, skipped"
+                % self.netloc)
             self.check_feed_health()
-            self.feed.save()            
+            self.feed.save()
             return
-    
+
         self.feed.last_checked_on = self.instant
-    
+
         # Check if we got a redirect first
         if response.history:
             status = response.history[0].status_code
@@ -166,153 +187,167 @@ def update_feed(self):
         try:
             handler = getattr(self, 'handle_%d' % status, None)
             if handler:
-                logger.debug(u"got status %s from server" % status)        
+                logger.debug("got status %s from server" % status)
                 handler(response)
-            else: 
+            else:
                 self.feed.last_status = status
-                logger.warn(u"%s replied with unhandled status %d, aborted" % (self.netloc, status))
+                logger.warn(
+                    "%s replied with unhandled status %d, aborted" % (
+                        self.netloc, status))
                 return
             self._parse_feed(response.text)
             self._fetch_icon()
-        except HTTPNotModified: 
-            pass # Nothing to do
+        except HTTPNotModified:
+            pass  # Nothing to do
         except (HTTPError, DuplicatedFeedError):
             self.check_feed_health()
         finally:
             self.feed.save()
 
     def check_feed_health(self):
-        if config.fetcher.max_errors and self.feed.error_count > config.fetcher.max_errors:
-            self._synthesize_entry('Feed has accumulated too many errors (last was %s).' % filters.status_title(self.feed.last_status))
-            logger.warn(u"%s has accomulated too many errors, disabled" % self.netloc)
-            self.feed.is_enabled = False        
-        return 
+        if config.fetcher.max_errors and \
+           self.feed.error_count > config.fetcher.max_errors:
+            self._synthesize_entry(
+                'Feed has accumulated too many errors (last was %s).'
+                % filters.status_title(self.feed.last_status))
+            logger.warn(
+                "%s has accomulated too many errors, disabled" % self.netloc)
+            self.feed.is_enabled = False
 
     def update_feed_with_data(self, data):
         self._parse_feed(data)
         self.feed.save()
-                      
+
     def _parse_feed(self, data):
 
-        soup = feedparser.parse(data)         
+        soup = feedparser.parse(data)
         # Got parsing error?
         if hasattr(soup, 'bozo') and soup.bozo:
-            logger.debug(u"%s caused a parser error (%s), tried to parse it anyway" % (self.netloc, soup.bozo_exception))
+            logger.debug(
+                "%s caused a parser error (%s), tried to parse it anyway" % (
+                    self.netloc, soup.bozo_exception))
 
         ft = FeedTranslator(soup.feed)
-        
-        self.feed.last_updated_on    = ft.get_timestamp(self.instant)        
-        self.feed.alternate_link     = ft.get_alternate_link()        
-        self.feed.title              = self.feed.title or ft.get_title() # Do not set again if already set
 
-        #entries = []
+        self.feed.last_updated_on = ft.get_timestamp(self.instant)
+        self.feed.alternate_link = ft.get_alternate_link()
+        self.feed.title = self.feed.title or ft.get_title()
+        # Do not set again if already set
+
         feed_author = ft.get_author()
 
         for entry_dict in soup.entries:
-    
+
             t = EntryTranslator(entry_dict)
-            
+
             link = t.get_link()
             guid = t.get_guid(default=link)
-    
+
             if not guid:
-                logger.warn(u'could not find GUID for entry from %s, skipped' % self.netloc)
+                logger.warn(
+                    'could not find GUID for entry from %s, skipped'
+                    % self.netloc)
                 continue
 
-            timestamp               = t.get_timestamp(self.instant)
-            content_type, content   = t.get_content(('text/plain', ''))
-        
-            # Skip ancient entries        
-            if config.fetcher.max_history and (self.instant - timestamp).days > config.fetcher.max_history:
-                logger.debug(u"entry %s from %s is over maximum history, skipped" % (guid, self.netloc))
+            timestamp = t.get_timestamp(self.instant)
+            content_type, content = t.get_content(('text/plain', ''))
+
+            # Skip ancient entries
+            if config.fetcher.max_history and (self.instant
+                                               - timestamp).days > \
+                    config.fetcher.max_history:
+                logger.debug(
+                    "entry %s from %s is over maximum history, skipped" % (
+                        guid, self.netloc))
                 continue
-    
+
             try:
-                # If entry is already in database with same hashed GUID, skip it
-                Entry.get(guid_hash=make_sha1_hash(guid)) 
-                logger.debug(u"duplicated entry %s, skipped" % guid)
+                # If entry is already in database with same hashed GUID, skip
+                Entry.get(guid_hash=make_sha1_hash(guid))
+                logger.debug("duplicated entry %s, skipped" % guid)
                 continue
             except Entry.DoesNotExist:
                 pass
-    
+
             entry = Entry(
-                feed              = self.feed,                
-                guid              = guid,
-                link              = link,
-                title             = t.get_title(default='Untitled'),
-                author            = t.get_author() or feed_author,
-                content           = content,
-                content_type      = content_type,
-                last_updated_on   = timestamp
+                feed=self.feed,
+                guid=guid,
+                link=link,
+                title=t.get_title(default='Untitled'),
+                author=t.get_author() or feed_author,
+                content=content,
+                content_type=content_type,
+                last_updated_on=timestamp
             )
-            
-            # At this point we are pretty sure we doesn't have the entry 
+
+            # At this point we are pretty sure we doesn't have the entry
             #  already in the database so alert plugins and save data
             trigger_event('entry_parsed', entry, entry_dict)
             entry.save()
-            #@@TODO: entries.append(entry)
-    
-            logger.debug(u"parsed entry %s from %s" % (guid, self.netloc))  
-        
-        #return entries
-        
+            #  @@TODO: entries.append(entry)
+
+            logger.debug("parsed entry %s from %s" % (guid, self.netloc))
 
     def _fetch_icon(self):
-    
-        if not self.feed.icon or not self.feed.icon_last_updated_on or (self.instant - self.feed.icon_last_updated_on).days > FETCH_ICONS_DELTA:
-            # Prefer alternate_link if available since self_link could 
-            #   point to Feed Burner or similar services
-            self.feed.icon                  = self._google_favicon_fetcher(self.feed.alternate_link or self.feed.self_link)
-            self.feed.icon_last_updated_on  = self.instant
 
-            logger.debug(u"fetched favicon %s..." % (self.feed.icon[:70]))    
+        if not self.feed.icon or not self.feed.icon_last_updated_on or \
+           (self.instant - self.feed.icon_last_updated_on).days > \
+                FETCH_ICONS_DELTA:
+            # Prefer alternate_link if available since self_link could
+            # point to Feed Burner or similar services
+            self.feed.icon = self._google_favicon_fetcher(
+                self.feed.alternate_link or self.feed.self_link)
+            self.feed.icon_last_updated_on = self.instant
+
+            logger.debug("fetched favicon %s..." % (self.feed.icon[:70]))
 
-    
     def _google_favicon_fetcher(self, url):
         '''
         Fetch a site favicon via Google service
         '''
-        endpoint = "http://www.google.com/s2/favicons?domain=%s" % urlparse.urlsplit(url).hostname
-    
+        endpoint = ("http://www.google.com/s2/favicons?domain=%s"
+                    % urllib.parse.urlsplit(url).hostname)
+
         try:
             response = fetch_url(endpoint)
-        except RequestException, exc:
-            logger.warn(u"could not fetch favicon for %s (%s)" % (url, exc))
+        except RequestException as exc:
+            logger.warn("could not fetch favicon for %s (%s)" % (url, exc))
             return Feed.DEFAULT_ICON
-    
-        return make_data_uri(response.headers['Content-Type'], response.content)
 
+        return make_data_uri(
+            response.headers['Content-Type'], response.content)
 
     def add_synthesized_entry(self, title, content_type, content):
         '''
-        Create an HTML entry for this feed 
+        Create an HTML entry for this feed
         '''
-    
-        # Since we don't know the mechanism the feed used to build a GUID for its entries 
-        #   synthesize an tag URI from the link and a random string. This makes
-        #   entries internally generated by Coldsweat reasonably globally unique
+        # Since we don't know the mechanism the feed used to build a GUID for
+        # its entries
+        # synthesize an tag URI from the link and a random string. This makes
+        # entries internally generated by Coldsweat reasonably globally unique
+
+        guid = ENTRY_TAG_URI % make_sha1_hash(self.feed.self_link +
+                                              make_nonce())
 
-        guid = ENTRY_TAG_URI % make_sha1_hash(self.feed.self_link + make_nonce())
-        
         entry = Entry(
-            feed              = self.feed,
-            guid              = guid,
-            title             = title,
-            author            = 'Coldsweat',
-            content           = content,
-            content_type      = content_type,
-            last_updated_on   = self.instant
+            feed=self.feed,
+            guid=guid,
+            title=title,
+            author='Coldsweat',
+            content=content,
+            content_type=content_type,
+            last_updated_on=self.instant
         )
         entry.save()
-        logger.debug(u"synthesized entry %s" % guid)    
+        logger.debug("synthesized entry %s" % guid)
         return entry
-    
 
-    def _synthesize_entry(self, reason):    
-        title   = u'This feed has been disabled'
-        content = render_template(os.path.join(template_dir, '_entry_feed_disabled.html'), {'reason': reason})
-        return self.add_synthesized_entry(title, 'text/html', content)                     
-        
+    def _synthesize_entry(self, reason):
+        title = 'This feed has been disabled'
+        content = render_template(
+            os.path.join(template_dir, '_entry_feed_disabled.html'),
+            {'reason': reason})
+        return self.add_synthesized_entry(title, 'text/html', content)
 
 
 def fetch_url(url, timeout=10, etag=None, modified_since=None):
@@ -326,27 +361,32 @@ def fetch_url(url, timeout=10, etag=None, modified_since=None):
 
     # Conditional GET headers
     if etag and modified_since:
-        logger.debug(u"fetching %s with a conditional GET (%s %s)" % (url, etag, format_http_datetime(modified_since)))
+        logger.debug(
+            "fetching %s with a conditional GET (%s %s)" %
+            (url, etag, format_http_datetime(modified_since)))
         request_headers['If-None-Match'] = etag
-        request_headers['If-Modified-Since'] = format_http_datetime(modified_since)
-        
+        request_headers['If-Modified-Since'] = format_http_datetime(
+            modified_since)
     try:
         response = requests.get(url, timeout=timeout, headers=request_headers)
-    except RequestException, exc:
-        logger.debug(u"tried to fetch %s but got %s" % (url, exc.__class__.__name__))
+    except RequestException as exc:
+        logger.debug(
+            "tried to fetch %s but got %s" % (url, exc.__class__.__name__))
         raise exc
-    
     return response
 
 # ------------------------------------------------------
-# Custom error codes 9xx & exceptions 
+# Custom error codes 9xx & exceptions
 # ------------------------------------------------------
 
+
 class DuplicatedFeedError(Exception):
-    code        = 900
-    title       = 'Duplicated feed'
-    explanation = 'Feed address matches another already present in the database.'
+    code = 900
+    title = 'Duplicated feed'
+    explanation = ('Feed address matches another already present in the'
+                   ' database.')
+
 
 # Update WebOb status codes map
-for klass in (DuplicatedFeedError,): 
+for klass in (DuplicatedFeedError,):
     status_map[klass.code] = klass
diff --git a/coldsweat/fever.py b/coldsweat/fever.py
index a187c5c..af10153 100644
--- a/coldsweat/fever.py
+++ b/coldsweat/fever.py
@@ -6,297 +6,321 @@
 Portions are copyright (c) 2013 Rui Carmo
 License: MIT (see LICENSE for details)
 """
-import re, json
+import json
+import re
 from collections import defaultdict
 from datetime import datetime, timedelta
 
-from webob import Request, Response
-from webob.exc import *
+from webob import Response
+from webob.exc import HTTPBadRequest
 from peewee import fn, IntegrityError
 
-from coldsweat import *
-from utilities import *    
-from app import *
-from controllers import *
-from models import *
-
-RE_DIGITS           = re.compile('[0-9]+')
-RECENTLY_READ_DELTA = 10*60 # 10 minutes
-API_VERSION         = 3
-COMMANDS = 'groups feeds items unread_item_ids saved_item_ids mark unread_recently_read favicons links'.split()
-    
+from coldsweat import logger
+from .utilities import Struct, datetime_as_epoch
+from .app import WSGIApp, POST
+from .controllers import FeedController, UserController
+from .models import (Entry, Feed, Group, Read, Saved, Subscription, User,
+                     transaction)
+
+RE_DIGITS = re.compile('[0-9]+')
+RECENTLY_READ_DELTA = 600  # 10 minutes
+API_VERSION = 3
+COMMANDS = ('groups feeds items unread_item_ids saved_item_ids mark '
+            'unread_recently_read favicons links'.split())
+
+
 class FeverApp(WSGIApp, FeedController, UserController):
 
     @POST(r'^/fever/?$')
     def endpoint(self):
-        logger.debug(u'client from %s requested: %s' % (self.request.remote_addr, self.request.params))
-        
+        logger.debug(u'client from %s requested: %s' % (
+            self.request.remote_addr, self.request.params))
+
         if 'api' not in self.request.GET:
             raise HTTPBadRequest()
-    
-        result = Struct({'api_version':API_VERSION, 'auth':0})   
-    
+
+        result = Struct({'api_version': API_VERSION, 'auth': 0})
+
         if 'api_key' in self.request.POST:
-            api_key = self.request.POST['api_key']        
+            api_key = self.request.POST['api_key']
             user = User.validate_api_key(api_key)
-            if not user: 
+            if not user:
                 logger.warn(u'unknown API key %s, unauthorized' % api_key)
-                return self.respond_with_json(result)  
+                return self.respond_with_json(result)
         else:
-            logger.warn(u'missing API key, unauthorized')               
+            logger.warn(u'missing API key, unauthorized')
             return self.respond_with_json(result)
 
         # Authorized
-        self.user = user        
-        result.auth = 1            
-            
+        self.user = user
+        result.auth = 1
+
         # It looks like client *can* send multiple commands at time
         for name in COMMANDS:
-            if name in self.request.params:           
+            if name in self.request.params:
                 try:
                     handler = getattr(self, '%s_command' % name)
                 except AttributeError:
-                    logger.debug(u'unrecognized command %s, skipped' % name) 
-                    continue        
-                handler(result)        
-    
+                    logger.debug(u'unrecognized command %s, skipped' % name)
+                    continue
+                handler(result)
+
         result.last_refreshed_on_time = get_last_refreshed_on_time()
-    
+
         return self.respond_with_json(result)
-        
+
     def respond_with_json(self, data):
         json_data = json.dumps(data, indent=4)
         response = Response(
-            json_data, 
+            json_data,
             content_type='application/json',
-            charset='utf-8')        
+            charset='utf-8')
         return response
 
     # ------------------------------------------------------
     # Fever API commands
-    # ------------------------------------------------------    
-    
-    def groups_command(self, result):            
+    # ------------------------------------------------------
+
+    def groups_command(self, result):
         q = self.get_groups()
         result.groups = [{
-            'id'    : group.id, 
-            'title' : group.title
+            'id': group.id,
+            'title': group.title
         } for group in q]
         result.feeds_groups = get_feed_groups(self.user)
-            
+
     def feeds_command(self, result):
         q = self.get_feeds()
         result.feeds = [{
-            'id'                  : feed.id,
-            'favicon_id'          : feed.id, 
-            'title'               : feed.title,
-            'url'                 : feed.self_link,
-            'site_url'            : feed.alternate_link,
-            'is_spark'            : 0, # Unsupported
-            'last_updated_on_time': feed.last_updated_on_as_epoch  
-        } for feed in q]        
+            'id': feed.id,
+            'favicon_id': feed.id,
+            'title': feed.title,
+            'url': feed.self_link,
+            'site_url': feed.alternate_link,
+            'is_spark': 0,  # Unsupported
+            'last_updated_on_time': feed.last_updated_on_as_epoch
+        } for feed in q]
         result.feeds_groups = get_feed_groups(self.user)
-    
+
     def unread_item_ids_command(self, result):
-        q = self.get_unread_entries(Entry.id).naive()        
+        q = self.get_unread_entries(Entry.id).objects()
         ids = [r.id for r in q]
         result.unread_item_ids = ','.join(map(str, ids))
-                
+
     def saved_item_ids_command(self, result):
-        q = self.get_saved_entries(Entry.id).naive()
+        q = self.get_saved_entries(Entry.id).objects()
         ids = [r.id for r in q]
         result.saved_item_ids = ','.join(map(str, ids))
-    
+
     def favicons_command(self, result):
         q = Feed.select()
         result.favicons = [{
             'id': feed.id,
             'data': feed.icon_or_default
         } for feed in q]
-    
+
     def items_command(self, result):
-    
+
         result.total_items = self.get_all_entries(Entry.id).count()
-    
-        # From the API: "Use the since_id argument with the highest id 
-        #  of locally cached items to request 50 additional items.         
-        if 'since_id' in self.request.GET: 
+        # From the API: "Use the since_id argument with the highest id
+        #  of locally cached items to request 50 additional items.
+        if 'since_id' in self.request.GET:
             try:
                 min_id = int(self.request.GET['since_id'])
-                result.items = get_entries_min(self.user, min_id)     
+                result.items = get_entries_min(self.user, min_id)
             except ValueError:
                 pass
-    
+
             return
-    
-        # From the API: "Use the max_id argument with the lowest id of locally 
-        #  cached items (or 0 initially) to request 50 previous items.                  
-        if 'max_id' in self.request.GET: 
+
+        # From the API: "Use the max_id argument with the lowest id of locally
+        #  cached items (or 0 initially) to request 50 previous items.
+        if 'max_id' in self.request.GET:
             try:
                 max_id = int(self.request.GET['max_id'])
-                if max_id: 
-                    result.items = get_entries_max(self.user, max_id)            
+                if max_id:
+                    result.items = get_entries_max(self.user, max_id)
             except ValueError:
                 pass
-    
+
             return
-            
-        # From the API: "Use the with_ids argument with a comma-separated list 
+
+        # From the API: "Use the with_ids argument with a comma-separated list
         #  of item ids to request (a maximum of 50) specific items."
-        if 'with_ids' in self.request.GET: 
-            with_ids = self.request.GET['with_ids']        
+        if 'with_ids' in self.request.GET:
+            with_ids = self.request.GET['with_ids']
             ids = [int(i) for i in with_ids.split(',') if RE_DIGITS.match(i)]
             result.items = get_entries(self.user, ids[:50])
             return
-        
+
         # Unfiltered results
         result.items = get_entries(self.user)
-    
-    
-    
-    def unread_recently_read_command(self, result):    
-        since = datetime.utcnow() - timedelta(seconds=RECENTLY_READ_DELTA)    
-        q = Read.delete().where((Read.user==self.user) & (Read.read_on > since)) 
+
+    def unread_recently_read_command(self, result):
+        since = datetime.utcnow() - timedelta(seconds=RECENTLY_READ_DELTA)
+        q = Read.delete().where((Read.user == self.user
+                                 ) & (Read.read_on > since))
         count = q.execute()
         logger.debug(u'%d entries marked as unread' % count)
-     
-        
-    
+
     def mark_command(self, result):
-    
+
         try:
-            mark, status, object_id = self.request.POST['mark'], self.request.POST['as'], int(self.request.POST['id'])
-        except (KeyError, ValueError), ex:
+            mark, status, object_id = self.request.POST['mark'], \
+                self.request.POST['as'], int(self.request.POST['id'])
+        except (KeyError, ValueError) as ex:
             logger.debug(u'missing or invalid parameter (%s), ignored' % ex)
-            return      
-    
+            return
+
         if mark == 'item':
-    
+
             try:
                 # Sanity check
-                entry = Entry.get(Entry.id == object_id)  
+                entry = Entry.get(Entry.id == object_id)
             except Entry.DoesNotExist:
                 logger.debug(u'could not find entry %d, ignored' % object_id)
                 return
-    
+
             if status == 'read':
                 try:
                     Read.create(user=self.user, entry=entry)
                 except IntegrityError:
-                    logger.debug(u'entry %d already marked as read, ignored' % object_id)
+                    logger.debug(
+                        u'entry %d already marked as read, ignored' %
+                        object_id)
                     return
-            # Strangely enough 'unread' is not mentioned in 
+            # Strangely enough 'unread' is not mentioned in
             #  the Fever API, but Reeder app asks for it
             elif status == 'unread':
-                count = Read.delete().where((Read.user==self.user) & (Read.entry==entry)).execute()
+                count = Read.delete().where(
+                    (Read.user == self.user) & (Read.entry == entry)).execute()
                 if not count:
-                    logger.debug(u'entry %d never marked as read, ignored' % object_id)
+                    logger.debug(
+                        u'entry %d never marked as read, ignored' % object_id)
                     return
             elif status == 'saved':
                 try:
                     Saved.create(user=self.user, entry=entry)
                 except IntegrityError:
-                    logger.debug(u'entry %d already marked as saved, ignored' % object_id)
+                    logger.debug(u'entry %d already marked as saved, ignored'
+                                 % object_id)
                     return
             elif status == 'unsaved':
-                count = Saved.delete().where((Saved.user==self.user) & (Saved.entry==entry)).execute()
+                count = Saved.delete().where((
+                    Saved.user == self.user) & (
+                    Saved.entry == entry)).execute()
                 if not count:
-                    logger.debug(u'entry %d never marked as saved, ignored' % object_id)
+                    logger.debug(
+                        u'entry %d never marked as saved, ignored' % object_id)
                     return
-                      
+
             logger.debug(u'marked entry %d as %s' % (object_id, status))
-    
-    
+
         elif mark == 'feed' and status == 'read':
-    
+
             try:
                 # Sanity check
-                feed = Feed.get(Feed.id == object_id)  
+                feed = Feed.get(Feed.id == object_id)
             except Feed.DoesNotExist:
                 logger.debug(u'could not find feed %d, ignored' % object_id)
                 return
-    
+
             # Unix timestamp of the the local client’s last items API request
             try:
-                before = datetime.utcfromtimestamp(int(self.request.POST['before']))
-            except (KeyError, ValueError), ex:
-                logger.debug(u'missing or invalid parameter (%s), ignored' % ex)
-                return              
-            
+                before = datetime.utcfromtimestamp(
+                    int(self.request.POST['before']))
+            except (KeyError, ValueError) as ex:
+                logger.debug(
+                    u'missing or invalid parameter (%s), ignored' % ex)
+                return
+
             q = Entry.select(Entry).join(Feed).join(Subscription).where(
                 (Subscription.user == self.user) &
-                (Subscription.feed == feed) & 
+                (Subscription.feed == feed) &
                 # Exclude entries already marked as read
-                ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user)) &
+                ~(Entry.id << Read.select(Read.entry).where(
+                    Read.user == self.user)) &
                 # Exclude entries fetched after last sync
                 (Entry.last_updated_on < before)
-            ).distinct().naive()
-    
+            ).distinct().objects()
+
             with transaction():
                 for entry in q:
                     try:
                         Read.create(user=self.user, entry=entry)
                     except IntegrityError:
-                        # Should not happen, due to the query above, log as warning
-                        logger.warn(u'entry %d already marked as read, ignored' % entry.id)
+                        # Should not happen,
+                        # due to the query above, log as warning
+                        logger.warn(
+                            u'entry %d already marked as read, ignored'
+                            % entry.id)
                         continue
-            
+
             logger.debug(u'marked feed %d as %s' % (object_id, status))
-                    
-    
+
         elif mark == 'group' and status == 'read':
-    
+
             # Unix timestamp of the the local client’s 'last items' API request
             try:
-                before = datetime.utcfromtimestamp(int(self.request.POST['before']))
-            except (KeyError, ValueError), ex:
-                logger.debug(u'missing or invalid parameter (%s), ignored' % ex)
-                return              
-    
+                before = datetime.utcfromtimestamp(
+                    int(self.request.POST['before']))
+            except (KeyError, ValueError) as ex:
+                logger.debug(
+                    u'missing or invalid parameter (%s), ignored' % ex)
+                return
+
             # Mark all as read?
-            if object_id == 0:                                                
+            if object_id == 0:
                 q = Entry.select(Entry).join(Feed).join(Subscription).where(
                     (Subscription.user == self.user) &
                     # Exclude entries already marked as read
-                    ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user)) &
+                    ~(Entry.id << Read.select(Read.entry).where(
+                        Read.user == self.user)) &
                     # Exclude entries fetched after last sync
                     (Entry.last_updated_on < before)
-                ).distinct().naive()
+                ).distinct().objects()
             else:
-                try:        
-                    group = Group.get(Group.id == object_id)  
+                try:
+                    group = Group.get(Group.id == object_id)
                 except Group.DoesNotExist:
-                    logger.debug(u'could not find group %d, ignored' % object_id)
+                    logger.debug(
+                        u'could not find group %d, ignored' % object_id)
                     return
-    
-                q = Entry.select(Entry).join(Feed).join(Subscription).where(
+
+                q = Entry.select(Entry).join(Feed).join(
+                        Subscription).where(
                     (Subscription.user == self.user) &
-                    (Subscription.group == group) & 
+                    (Subscription.group == group) &
                     # Exclude entries already marked as read
-                    ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user)) &
+                    ~(Entry.id << Read.select(
+                        Read.entry).where(Read.user == self.user)) &
                     # Exclude entries fetched after last sync
                     (Entry.last_updated_on < before)
-                ).distinct().naive()
-    
+                ).distinct().objects()
+
             with transaction():
-                for entry in q:                
+                for entry in q:
                     try:
                         Read.create(user=self.user, entry=entry)
                     except IntegrityError:
-                        # Should not happen thanks to the query above, log as warning
-                        logger.warn(u'entry %d already marked as read, ignored' % entry.id)
+                        # Should not happen thanks to the query above,
+                        # log as warning
+                        logger.warn(
+                            u'entry %d already marked as read, ignored'
+                            % entry.id)
                         continue
-            
+
             logger.debug(u'marked group %d as %s' % (object_id, status))
-    
-        else:   
-            logger.debug(u'malformed mark command (mark %s (%s) as %s ), ignored' % (mark, object_id, status))
-    
-    
-    
+
+        else:
+            logger.debug(
+                u'malformed mark command (mark %s (%s) as %s ), ignored' % (
+                    mark, object_id, status))
+
     def links_command(self, result):
         # Hot links (unsupported)
-        result.links = []     
+        result.links = []
+
 
 def setup_app():
     return FeverApp()
@@ -304,26 +328,29 @@ def setup_app():
 # ------------------------------------------------------
 # Specific Fever queries
 # ------------------------------------------------------
-        
+
+
 def get_feed_groups(user):
-    q = Subscription.select(Subscription, Feed, Group).join(Feed).switch(Subscription).join(Group).where(Subscription.user == user)
+    q = Subscription.select(Subscription,
+                            Feed, Group).join(Feed).switch(Subscription).join(
+                            Group).where(Subscription.user == user)
     groups = defaultdict(lambda: [])
     for s in q:
         groups[s.group.id].append(str(s.feed.id))
     result = []
     for g in groups:
-        result.append({'group_id':g, 'feed_ids':','.join(groups[g])})
+        result.append({'group_id': g, 'feed_ids': ','.join(groups[g])})
     return result
 
 
 def _get_entries(user, q):
 
-    r = Entry.select(Entry.id).join(Read).where(Read.user == user).naive()
-    s = Entry.select(Entry.id).join(Saved).where(Saved.user == user).naive()
-    
-    read_ids    = dict((i.id, None) for i in r)
-    saved_ids   = dict((i.id, None) for i in s)
-    
+    r = Entry.select(Entry.id).join(Read).where(Read.user == user).objects()
+    s = Entry.select(Entry.id).join(Saved).where(Saved.user == user).objects()
+
+    read_ids = dict((i.id, None) for i in r)
+    saved_ids = dict((i.id, None) for i in s)
+
     result = []
     for entry in q:
         result.append({
@@ -337,25 +364,36 @@ def _get_entries(user, q):
             'is_read': 1 if entry.id in read_ids else 0,
             'created_on_time': entry.last_updated_on_as_epoch
         })
-    return result 
-    
+    return result
+
+
 def get_entries(user, ids=None):
 
     if ids:
         where_clause = (Subscription.user == user) & (Entry.id << ids)
     else:
         where_clause = (Subscription.user == user)
-    
-    q = Entry.select(Entry, Feed).join(Feed).join(Subscription).where(where_clause).distinct()
-    return _get_entries(user, q) 
+
+    q = Entry.select(Entry,
+                     Feed).join(Feed).join(
+                        Subscription).where(where_clause).distinct()
+    return _get_entries(user, q)
+
 
 def get_entries_min(user, min_id, bound=50):
-    q = Entry.select(Entry, Feed).join(Feed).join(Subscription).where((Subscription.user == user) & (Entry.id > min_id)).distinct().limit(bound)
-    return _get_entries(user, q) 
+    q = Entry.select(Entry, Feed).join(
+        Feed).join(Subscription).where((Subscription.user == user)
+                                       & (Entry.id > min_id
+                                          )).distinct().limit(bound)
+    return _get_entries(user, q)
+
 
 def get_entries_max(user, max_id, bound=50):
-    q = Entry.select(Entry, Feed).join(Feed).join(Subscription).where((Subscription.user == user) & (Entry.id < max_id)).distinct().limit(bound)
-    return _get_entries(user, q) 
+    q = Entry.select(Entry,
+                     Feed).join(Feed).join(Subscription).where(
+            (Subscription.user == user) &
+            (Entry.id < max_id)).distinct().limit(bound)
+    return _get_entries(user, q)
 
 
 def get_last_refreshed_on_time():
@@ -363,9 +401,8 @@ def get_last_refreshed_on_time():
     Time of the most recently *refreshed* feed
     """
     last_checked_on = Feed.select().aggregate(fn.Max(Feed.last_checked_on))
-    if last_checked_on:        
+    if last_checked_on:
         return datetime_as_epoch(last_checked_on)
-            
+
     # Return a fallback value
     return datetime_as_epoch(datetime.utcnow())
-
diff --git a/coldsweat/filters.py b/coldsweat/filters.py
index fb710e3..9f2d6f6 100644
--- a/coldsweat/filters.py
+++ b/coldsweat/filters.py
@@ -5,13 +5,17 @@
 Copyright (c) 2013—2016 Andrea Peltrin
 License: MIT (see LICENSE for details)
 """
-import re, cgi, urllib, urlparse
+import html
+import urllib.request, urllib.parse, urllib.error
+import urllib.parse as urlparse
+
 from webob.exc import status_map
-import utilities
+from . import utilities
 
 __all__ = []
 
-def filter(filtername):        
+
+def filter(filtername):
     '''
     Define a filter
     '''
@@ -20,77 +24,88 @@ def _(handler):
         __all__.append(handler.__name__)
         return handler
     return _
-    
 # ------------------------------------------------------
 # Filters
 # ------------------------------------------------------
 
+
 @filter('html')
-def escape_html(value):     
-    if value:    
-        return cgi.escape(value, quote=True)
+def escape_html(value):
+    if value:
+        return html.escape(value, quote=True)
     return ''
 
+
 @filter('url')
-def escape_url(value):     
+def escape_url(value):
     if value:
-        return urllib.quote(utilities.encode(value))
+        return urllib.parse.quote(utilities.encode(value))
     return ''
 
+
 @filter('friendly_url')
 def friendly_url(value):
     if value:
-        u = urlparse.urlsplit(value)
-        return u.netloc 
+        u = urllib.parse.urlsplit(value)
+        return u.netloc
     return ''
 
+
 @filter('capitalize')
 def capitalize(value):
-    if value:        
+    if value:
         return value.capitalize()
     return ''
 
+
 @filter('length')
 def length(value):
     if value:
         return len(list(value))
     return 0
-        
+
+
 @filter('datetime')
 def datetime(value):
-    if value:        
+    if value:
         return utilities.format_datetime(value)
     return '—'
 
+
 @filter('iso_datetime')
 def iso_datetime(value):
-    if value:        
+    if value:
         return utilities.format_iso_datetime(value)
-    return ''  
+    return ''
+
 
 @filter('date')
 def date(value):
-    if value:        
+    if value:
         return utilities.format_date(value)
     return '—'
 
+
 @filter('since')
-def datetime_since(value):                                
+def datetime_since(value):
     if value:
         return utilities.datetime_since(value)
-    return '—' 
+    return '—'
+
 
 @filter('since_today')
 def datetime_since_today(value):
     if value:
         return utilities.datetime_since_today(value)
-    return '—' 
+    return '—'
+
 
 @filter('epoch')
-def epoch(value):                                
+def epoch(value):
     if value:
         return utilities.datetime_as_epoch(value)
-    return '—' 
+    return '—'
+
 
 @filter('status_title')
 def status_title(code):
@@ -98,25 +113,26 @@ def status_title(code):
     try:
         return status_map[code].title
     except KeyError:
-        pass 
+        pass
     return title
-    
+
+
 @filter('alert')
 def alert(message):
     if not message:
-        return ''        
-    try: 
-        klass, text = message.split(u' ', 1)
+        return ''
+    try:
+        klass, text = message.split(' ', 1)
     except ValueError:
         return text
-    return u'<div class="alert alert--%s">%s</div>' % (klass.lower(), text)
-    
+    return '<div class="alert alert--%s">%s</div>' % (klass.lower(), text)
+
+
 def run_tests():
-    
-    #t = datetime.utcnow()        
-            
+
     assert friendly_url('http://example.org/feed.xml') == 'example.org'
     assert friendly_url(None) == ''
-    
+
+
 if __name__ == '__main__':
     run_tests()
diff --git a/coldsweat/frontend.py b/coldsweat/frontend.py
index 19f34ba..785df53 100644
--- a/coldsweat/frontend.py
+++ b/coldsweat/frontend.py
@@ -7,137 +7,186 @@
 License: MIT (see LICENSE for details)
 """
 import os
-from datetime import datetime, timedelta
+from datetime import datetime
 from functools import wraps
-from webob import Request, Response
-from webob.exc import *
-from tempita import Template
+from webob import Response
+from webob.exc import (HTTPBadRequest,
+                       HTTPNotFound,
+                       HTTPSeeOther,
+                       HTTPTemporaryRedirect)
 from peewee import fn, IntegrityError
-
-from coldsweat import *
-from app import *
-from models import *
-from controllers import *
-from utilities import *
-from fetcher import *
-from markup import *
-from session import SessionMiddleware
-import filters
-from plugins import trigger_event, load_plugins
-
-ENTRIES_PER_PAGE    = 30
-FEEDS_PER_PAGE      = 60
-GROUPS_PER_PAGE     = 30
-USER_SESSION_KEY    = 'FrontendApp.user'
-COOKIE_SESSION_KEY  = '_SID_'
-
-def login_required(handler): 
+from requests import RequestException
+from tempita import Template as BaseTemplate
+
+from .app import GET, POST, WSGIApp, form
+from coldsweat import config, logger, template_dir, VERSION_STRING
+from .fetcher import fetch_url, Fetcher
+from .markup import sniff_feed, find_feed_links
+from .models import (Entry, Group, Feed, Read, Saved, User,
+                     Subscription, transaction)
+
+from .plugins import trigger_event
+from .controllers import FeedController, UserController
+from .session import SessionMiddleware
+from .utilities import format_datetime, validate_url
+from . import filters
+
+ENTRIES_PER_PAGE = 30
+FEEDS_PER_PAGE = 60
+GROUPS_PER_PAGE = 30
+USER_SESSION_KEY = 'FrontendApp.user'
+COOKIE_SESSION_KEY = '_SID_'
+
+
+def unicode_get_file_template(name, from_template, encoding='utf-8'):
+    """
+    Monkey patch Template because it failes to pass utf-8 to all templates
+    """
+    path = os.path.join(os.path.dirname(from_template.name), name)
+    return from_template.__class__.from_filename(
+        path, namespace=from_template.namespace,
+        get_template=from_template.get_template,
+        encoding=encoding)
+
+
+class Template(BaseTemplate):
+
+    @classmethod
+    def from_filename(cls,
+                      filename,
+                      namespace=None,
+                      encoding=None,
+                      default_inherit=None,
+                      get_template=unicode_get_file_template):
+
+        f = open(filename, 'rb')
+        c = f.read()
+        f.close()
+        if encoding:
+            c = c.decode(encoding)
+        return cls(content=c, name=filename, namespace=namespace,
+                   default_inherit=default_inherit, get_template=get_template)
+
+
+def login_required(handler):
     @wraps(handler)
     def wrapper(self, *args):
         if self.user:
             return handler(self, *args)
         else:
-            raise self.redirect('%s/login?from=%s' % (self.application_url, filters.escape_url(self.request.url)))    
+            raise self.redirect(
+                '%s/login?from=%s' % (
+                    self.application_url,
+                    filters.escape_url(self.request.url)))
     return wrapper
-    
+
 
 class FrontendApp(WSGIApp, FeedController, UserController):
 
     def __init__(self):
         super(FrontendApp, self).__init__()
-    
+
         self.alert_message = ''
         self.app_namespace = {
-            'version_string'    : VERSION_STRING,
-            'static_url'        : config.web.static_url,
-            'alert_message'     : '',
-            'page_title'        : '',
+            'version_string': VERSION_STRING,
+            'static_url': config.web.static_url,
+            'alert_message': '',
+            'page_title': '',
         }
         # Install template filters
         for name in filters.__all__:
             filter = getattr(filters, name)
             self.app_namespace[filter.name] = filter
 
-    def _make_view_variables(self): 
-        
-        count, group_id, feed_id, filter_name, filter_class, panel_title, page_title = 0, 0, 0, '', '', '', ''
-        
-        groups = self.get_groups()    
-        r = Entry.select(Entry.id).join(Read).where((Read.user == self.user)).naive()
-        s = Entry.select(Entry.id).join(Saved).where((Saved.user == self.user)).naive()
-        read_ids    = dict((i.id, None) for i in r)
-        saved_ids   = dict((i.id, None) for i in s)
-        
+    def _make_view_variables(self):
+
+        count, group_id, feed_id, filter_name, \
+                filter_class, panel_title, page_title = 0, 0, 0, '', '', '', ''
+
+        groups = self.get_groups()
+        r = Entry.select(Entry.id).join(Read).where((Read.user
+                                                     == self.user)).objects()
+        s = Entry.select(Entry.id).join(Saved).where((Saved.user
+                                                      == self.user)).objects()
+        read_ids = dict((i.id, None) for i in r)
+        saved_ids = dict((i.id, None) for i in s)
+
         if 'saved' in self.request.GET:
-            count, q = self.get_saved_entries(Entry.id).count(), self.get_saved_entries()
+            count = self.get_saved_entries(Entry.id).count()
+            q = self.get_saved_entries()
             panel_title = 'Saved'
             filter_class = filter_name = 'saved'
             page_title = 'Saved'
         elif 'group' in self.request.GET:
-            group_id = int(self.request.GET['group'])    
-            group = Group.get(Group.id == group_id) 
-            count, q = self.get_group_entries(group, Entry.id).count(), self.get_group_entries(group)
-            panel_title = group.title                
-            filter_class = 'groups' # The same when listing group
+            group_id = int(self.request.GET['group'])
+            group = Group.get(Group.id == group_id)
+            count = self.get_group_entries(group, Entry.id).count()
+            q = self.get_group_entries(group)
+            panel_title = group.title
+            filter_class = 'groups'  # The same when listing group
             filter_name = 'group=%s' % group_id
             page_title = group.title
         elif 'feed' in self.request.GET:
             feed_id = int(self.request.GET['feed'])
-            feed = Feed.get(Feed.id == feed_id) 
-            count, q = self.get_feed_entries(feed, Entry.id).count(), self.get_feed_entries(feed)
+            feed = Feed.get(Feed.id == feed_id)
+            count = self.get_feed_entries(feed, Entry.id).count()
+            q = self.get_feed_entries(feed)
             panel_title = feed.title
             filter_class = 'feeds'
             filter_name = 'feed=%s' % feed_id
             page_title = feed.title
         elif 'all' in self.request.GET:
-            count, q = self.get_all_entries(Entry.id).count(), self.get_all_entries()
-            panel_title = 'All'                
+            count = self.get_all_entries(Entry.id).count()
+            q = self.get_all_entries()
+            panel_title = 'All'
             filter_class = filter_name = 'all'
             page_title = 'All'
-        else: # Default
-            count, q = self.get_unread_entries(Entry.id).count(), self.get_unread_entries()
+        else:  # Default
+            count = self.get_unread_entries(Entry.id).count()
+            q = self.get_unread_entries()
             panel_title = 'Unread'
             filter_class = filter_name = 'unread'
             page_title = 'Unread'
-                    
+
         # Cleanup namespace
         del r, s, self
-        
+
         return q, locals()
-                        
+
     # Views
 
     @GET(r'^/$')
-    @login_required         
+    @login_required
     def index(self):
         return self.entry_list()
 
     # Entries
 
     @GET(r'^/entries/(\d+)$')
-    @login_required        
+    @login_required
     def entry(self, entry_id):
         try:
-            entry = Entry.get((Entry.id == entry_id)) 
+            entry = Entry.get((Entry.id == entry_id))
         except Entry.DoesNotExist:
             raise HTTPNotFound('No such entry %s' % entry_id)
 
-        self.mark_entry(entry, 'read')                                
+        self.mark_entry(entry, 'read')
 
         q, namespace = self._make_view_variables()
-        n = q.where(Entry.last_updated_on < entry.last_updated_on).order_by(Entry.last_updated_on.desc()).limit(1)
+        n = q.where(Entry.last_updated_on < entry.last_updated_on).order_by(
+                    Entry.last_updated_on.desc()).limit(1)
 
         namespace.update({
             'entry': entry,
             'page_title': entry.title,
-            'next_entries': n,            
-            'count': 0 # Fake it
+            'next_entries': n,
+            'count': 0  # Fake it
         })
 
-        return self.respond_with_template('entry.html', namespace)   
-        
+        return self.respond_with_template('entry.html', namespace)
+
     @POST(r'^/entries/(\d+)$')
-    @login_required    
+    @login_required
     def entry_post(self, entry_id):
         '''
         Mark an entry as read, unread, saved and unsaved
@@ -145,41 +194,40 @@ def entry_post(self, entry_id):
         try:
             status = self.request.POST['as']
         except KeyError:
-            raise HTTPBadRequest('Missing parameter as=read|unread|saved|unsaved')
+            raise HTTPBadRequest(
+                'Missing parameter as=read|unread|saved|unsaved')
 
         try:
-            entry = Entry.get((Entry.id == entry_id)) 
+            entry = Entry.get((Entry.id == entry_id))
         except Entry.DoesNotExist:
             raise HTTPNotFound('No such entry %s' % entry_id)
-    
-        if 'mark' in self.request.POST:
-            self.mark_entry(entry, status)                        
 
+        if 'mark' in self.request.POST:
+            self.mark_entry(entry, status)
 
     @GET(r'^/entries/?$')
-    @login_required    
+    @login_required
     def entry_list(self):
         '''
-        Show entries filtered and possibly paginated by: 
+        Show entries filtered and possibly paginated by:
             unread, saved, group or feed
         '''
         q, namespace = self._make_view_variables()
 
-        offset = int(self.request.GET.get('offset', 0))            
-        entries = q.order_by(Entry.last_updated_on.desc()).offset(offset).limit(ENTRIES_PER_PAGE)
-        
+        offset = int(self.request.GET.get('offset', 0))
+
         namespace.update({
-            'entries'   : q.order_by(Entry.last_updated_on.desc()).offset(offset).limit(ENTRIES_PER_PAGE),
-            'offset'    : offset + ENTRIES_PER_PAGE,
-            'prev_date' : self.request.GET.get('prev_date', None),
-            #'count'     : count
+            'entries': q.order_by(
+                Entry.last_updated_on.desc()
+            ).offset(offset).limit(ENTRIES_PER_PAGE),
+            'offset': offset + ENTRIES_PER_PAGE,
+            'prev_date': self.request.GET.get('prev_date', None),
         })
-        
-        return self.respond_with_template('entries.html', namespace)
 
+        return self.respond_with_template('entries.html', namespace)
 
     @form(r'^/entries/mark$')
-    @login_required    
+    @login_required
     def entry_list_post(self):
         '''
         Mark feed|all entries as read
@@ -187,356 +235,378 @@ def entry_list_post(self):
         feed_id = int(self.request.GET.get('feed', 0))
 
         if self.request.method == 'GET':
-            now = datetime.utcnow()          
-            return self.respond_with_template('_entries_mark_%s_read.html' % ('feed' if feed_id else 'all'), locals())
+            now = datetime.utcnow()
+            return self.respond_with_template(
+                '_entries_mark_%s_read.html' % (
+                    'feed' if feed_id else 'all'), locals())
 
         # Handle postback
         try:
-            before = datetime.utcfromtimestamp(int(self.request.POST['before']))
+            before = datetime.utcfromtimestamp(
+                int(self.request.POST['before']))
         except (KeyError, ValueError):
             raise HTTPBadRequest('Missing parameter before=time')
-        
+
         if feed_id:
             try:
-                feed = Feed.get((Feed.id == feed_id)) 
+                feed = Feed.get((Feed.id == feed_id))
             except Feed.DoesNotExist:
                 raise HTTPNotFound('No such feed %s' % feed_id)
-            
+
             q = Entry.select(Entry).join(Feed).join(Subscription).where(
-                (Subscription.user == self.user) &            
+                (Subscription.user == self.user) &
                 # Exclude entries already marked as read
-                ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user)) &
+                ~(Entry.id << Read.select(Read.entry).where(
+                    Read.user == self.user)) &
                 # Filter by current feed
                 (Entry.feed == feed) &
-                # Exclude entries fetched after the page load                
-                (Feed.last_checked_on < before) 
+                # Exclude entries fetched after the page load
+                (Feed.last_checked_on < before)
             ).distinct()
             message = 'SUCCESS Feed has been marked as read'
-            redirect_url = '%s/entries/?feed=%s' % (self.application_url, feed_id)
+            redirect_url = '%s/entries/?feed=%s' % (
+                self.application_url, feed_id)
         else:
             q = Entry.select(Entry).join(Feed).join(Subscription).where(
-                (Subscription.user == self.user) &            
+                (Subscription.user == self.user) &
                 # Exclude entries already marked as read
-                ~(Entry.id << Read.select(Read.entry).where(Read.user == self.user)) &
+                ~(Entry.id << Read.select(Read.entry).where(
+                    Read.user == self.user)) &
                 # Exclude entries fetched after the page load
-                (Feed.last_checked_on < before) 
+                (Feed.last_checked_on < before)
             ).distinct()
             message = 'SUCCESS All entries have been marked as read'
             redirect_url = '%s/entries/?unread' % self.application_url
-        
-        #@@TODO: Use insert_many()
+
+        #  @@TODO: Use insert_many()
         with transaction():
             for entry in q:
                 try:
                     Read.create(user=self.user, entry=entry)
                 except IntegrityError:
-                    logger.debug(u'entry %d already marked as read, ignored' % entry.id)
-                    continue                     
-        
-        self.alert_message = message        
-        return self.respond_with_script('_modal_done.js', {'location': redirect_url})
+                    logger.debug(
+                        'entry %d already marked as read, ignored' % entry.id)
+                    continue
+
+        self.alert_message = message
+        return self.respond_with_script('_modal_done.js',
+                                        {'location': redirect_url})
 
     # Groups
 
     @GET(r'^/groups/?$')
-    @login_required    
+    @login_required
     def group_list(self):
         '''
         Show feed groups for current user
         '''
-        offset, group_id, filter_class, panel_title, page_title = 0, 0, 'groups', 'Groups', 'Groups'
+        offset, group_id, filter_class, panel_title, page_title = \
+            0, 0, 'groups', 'Groups', 'Groups'
 
-        count, q = self.get_groups().count(), self.get_groups()  
+        count, q = self.get_groups().count(), self.get_groups()
         offset = int(self.request.GET.get('offset', 0))
         groups = q.offset(offset).limit(GROUPS_PER_PAGE)
         offset += GROUPS_PER_PAGE
-        
-        return self.respond_with_template('groups.html', locals())  
+
+        return self.respond_with_template('groups.html', locals())
 
     # Feeds
 
     @GET(r'^/feeds/?$')
-    @login_required    
+    @login_required
     def feed_list(self):
         '''
         Show subscribed feeds for current user
         '''
-        offset, group_id, feed_id, filter_class, panel_title, page_title = 0, 0, 0, 'feeds', 'Feeds', 'Feeds'
+        offset, group_id, feed_id, filter_class, panel_title, page_title = \
+            0, 0, 0, 'feeds', 'Feeds', 'Feeds'
 
         max_errors = config.fetcher.max_errors
-        groups = self.get_groups()  
+        groups = self.get_groups()
         offset = int(self.request.GET.get('offset', 0))
         count, q = self.get_feeds(Feed.id).count(), self.get_feeds()
         feeds = q.order_by(Feed.title).offset(offset).limit(FEEDS_PER_PAGE)
         offset += FEEDS_PER_PAGE
-        
-        return self.respond_with_template('feeds.html', locals())  
 
+        return self.respond_with_template('feeds.html', locals())
 
     @form(r'^/feeds/edit/(\d+)$')
-    @login_required    
-    def feed(self, feed_id):        
-        form_message = ''        
+    @login_required
+    def feed(self, feed_id):
+        form_message = ''
         try:
-            feed = Feed.get(Feed.id == feed_id) 
+            feed = Feed.get(Feed.id == feed_id)
         except Feed.DoesNotExist:
             raise HTTPNotFound('No such feed %s' % feed_id)
-        
-        # Collect editable fields 
+
+        # Collect editable fields
         title = feed.title
 
-        q = Subscription.select(Subscription, Group).join(Group).where((Subscription.user == self.user) & (Subscription.feed == feed))
+        q = Subscription.select(
+                Subscription, Group).join(Group).where(
+                    (Subscription.user == self.user
+                     ) & (Subscription.feed == feed))
         groups = [s.group for s in q]
-        
+
         if self.request.method == 'GET':
             return self.respond_with_template('_feed_edit.html', locals())
 
         # Handle postback
         form = self.request.POST
-        
+
         title = form.get('title', '').strip()
-        if not title:                
-            form_message = u'ERROR Error, feed title cannot be empty'
+        if not title:
+            form_message = 'ERROR Error, feed title cannot be empty'
             return self.respond_with_template('_feed_edit.html', locals())
         feed.title = title
         feed.save()
-        self.alert_message = u'SUCCESS Changes have been saved.'
-        return self.respond_with_script('_modal_done.js', {'location': '%s/feeds/' % self.application_url}) 
+        self.alert_message = 'SUCCESS Changes have been saved.'
+        return self.respond_with_script(
+            '_modal_done.js', {'location': '%s/feeds/' % self.application_url})
 
-        
     @form(r'^/feeds/remove/(\d+)$')
-    @login_required    
+    @login_required
     def feed_remove(self, feed_id):
 
         try:
-            feed = Feed.get(Feed.id == feed_id) 
+            feed = Feed.get(Feed.id == feed_id)
         except Feed.DoesNotExist:
             raise HTTPNotFound('No such feed %s' % feed_id)
 
         if self.request.method == 'GET':
-            return self.respond_with_modal('%s/feeds/remove/%d' % (self.application_url, feed.id), 
-                title=u'Remove <i>%s</i> from your subscriptions?' % feed.title, button='Remove')
+            return self.respond_with_modal(
+                '%s/feeds/remove/%d' % (self.application_url, feed.id),
+                title='Remove <i>%s</i> from your subscriptions?'
+                % feed.title, button='Remove')
 
         # Handle postback
-        Subscription.delete().where((Subscription.user == self.user) & (Subscription.feed == feed)).execute()
-        self.alert_message = u'SUCCESS You are no longer subscribed to <i>%s</i>.' % feed.title  
+        Subscription.delete().where(
+            (Subscription.user == self.user
+             ) & (Subscription.feed == feed)).execute()
+        self.alert_message = (
+            'SUCCESS You are no longer subscribed to <i>%s</i>.' % feed.title)
 
         return self.redirect_after_post('%s/feeds/' % self.application_url)
 
-
     @form(r'^/feeds/enable/(\d+)$')
-    @login_required    
+    @login_required
     def feed_enable(self, feed_id):
 
-        #@@TODO: Track in which view user triggers command
-        
+        #  @@TODO: Track in which view user triggers command
+
         try:
-            feed = Feed.get(Feed.id == feed_id) 
+            feed = Feed.get(Feed.id == feed_id)
         except Feed.DoesNotExist:
             raise HTTPNotFound('No such feed %s' % feed_id)
 
         if self.request.method == 'GET':
-            return self.respond_with_modal('%s/feeds/enable/%d' % (self.application_url, feed.id), 
-                title=u'Enable <i>%s</i> again?' % feed.title, 
-                body='Coldsweat will attempt to update it again during the next feeds fetch.', 
+            return self.respond_with_modal(
+                '%s/feeds/enable/%d' % (self.application_url, feed.id),
+                title='Enable <i>%s</i> again?' % feed.title,
+                body=('Coldsweat will attempt to update '
+                      'it again during the next feeds fetch.'),
                 button='Enable')
-        
+
         # Handle postback
-        feed.is_enabled, feed.error_count  = True, 0
+        feed.is_enabled, feed.error_count = True, 0
         feed.save()
-        self.alert_message = u'SUCCESS Feed <i>%s</i> is now enabled.' % feed.title  
+        self.alert_message = ('SUCCESS Feed <i>%s</i> is now enabled.'
+                              % feed.title)
 
         return self.redirect_after_post('%s/feeds/' % self.application_url)
 
-
     @form(r'^/feeds/add/1$')
-    @login_required    
-    def feed_add_1(self):        
+    @login_required
+    def feed_add_1(self):
         form_message = ''
         groups = self.get_groups()
 
-        # URL could be passed via a GET (bookmarklet) or POST 
+        # URL could be passed via a GET (bookmarklet) or POST
         self_link = self.request.params.get('self_link', '').strip()
-        
+
         if self.request.method == 'GET':
-            return self.respond_with_template('_feed_add_wizard_1.html', locals())
+            return self.respond_with_template('_feed_add_wizard_1.html',
+                                              locals())
 
         # Handle POST
-        
+
         group_id = int(self.request.POST.get('group', 0))
 
-        # Assume HTTP if URL is passed w/out scheme        
-        self_link = self_link if self_link.startswith('http') else u'http://' + self_link 
-        
+        # Assume HTTP if URL is passed w/out scheme
+        self_link = self_link if self_link.startswith('http') \
+            else 'http://' + self_link
+
         if not validate_url(self_link):
-            form_message = u'ERROR Error, specify a valid web address'
-            return self.respond_with_template('_feed_add_wizard_1.html', locals())
-                
+            form_message = 'ERROR Error, specify a valid web address'
+            return self.respond_with_template('_feed_add_wizard_1.html',
+                                              locals())
+
         try:
             response = fetch_url(self_link)
-        except RequestException, exc:
-            form_message = u'ERROR Error, feed address is incorrect or host is unreachable.'
-            return self.respond_with_template('_feed_add_wizard_1.html', locals())
-        #else:
-            #form_message = u'ERROR Error, a network error occured'
-            #return self.respond_with_template('_feed_add_wizard_1.html', locals())
-
+        except RequestException:
+            form_message = ('ERROR Error, feed address is incorrect or '
+                            'host is unreachable.')
+            return self.respond_with_template('_feed_add_wizard_1.html',
+                                              locals())
         if not sniff_feed(response.text):
             links = find_feed_links(response.text, base_url=self_link)
-            return self.respond_with_template('_feed_add_wizard_2.html', locals())
+            return self.respond_with_template('_feed_add_wizard_2.html',
+                                              locals())
 
         # It's a feed
-        
-        feed = self.add_feed_from_url(self_link, fetch_data=False)        
-        logger.debug(u"starting fetcher")
-        trigger_event('fetch_started')        
-        Fetcher(feed).update_feed_with_data(response.text)        
+
+        feed = self.add_feed_from_url(self_link, fetch_data=False)
+        logger.debug("starting fetcher")
+        trigger_event('fetch_started')
+        Fetcher(feed).update_feed_with_data(response.text)
         trigger_event('fetch_done', [feed])
-        
+
         return self._add_subscription(feed, group_id)
-        
 
     @POST(r'^/feeds/add/2$')
-    @login_required 
-    def feed_add_2(self):  
+    @login_required
+    def feed_add_2(self):
 
         self_link = self.request.POST.get('self_link', '')
         group_id = int(self.request.POST.get('group', 0))
 
-#@@TODO: handle multiple feed subscription
+# @@TODO: handle multiple feed subscription
 #         urls = self.request.POST.getall('feeds')
 #         for url in urls:
 #             pass
-#@@TODO: validate feed
+# @@TODO: validate feed
 #         try:
 #             response = fetch_url(self_link)
 #         except RequestException, exc:
-#             form_message = u'ERROR Error, feed address is incorrect or host is unreachable.'
-#             return self.respond_with_template('_feed_add_wizard_1.html', locals())
-            
+#             form_message = (u'ERROR Error, feed address is incorrect or '
+#                              'host is unreachable.')
+#             return self.respond_with_template('_feed_add_wizard_1.html',
+#                                               locals())
+
         feed = self.add_feed_from_url(self_link, fetch_data=True)
         return self._add_subscription(feed, group_id)
-                
+
 #     def _parse_feed(self, self_link):
 #         feed = self.add_feed_from_url(self_link, fetch_data=False)
 #         ff = Fetcher(feed)
 #         ff.parse_feed(response.text)
 
-                
     def _add_subscription(self, feed, group_id):
         if group_id:
-            group = Group.get(Group.id == group_id) 
+            group = Group.get(Group.id == group_id)
         else:
             group = Group.get(Group.title == Group.DEFAULT_GROUP)
 
         subscription = self.add_subscription(feed, group)
         if subscription:
-            self.alert_message = u'SUCCESS Feed has been added to <i>%s</i> group' % group.title
+            self.alert_message = ('SUCCESS Feed has been added to '
+                                  '<i>%s</i> group' % group.title)
         else:
-            self.alert_message = u'INFO Feed is already in <i>%s</i> group' % group.title
-        return self.respond_with_script('_modal_done.js', {'location': '%s/?feed=%d' % (self.application_url, feed.id)})     
-    
+            self.alert_message = ('INFO Feed is already in <i>%s</i> group'
+                                  % group.title)
+        return self.respond_with_script(
+            '_modal_done.js', {'location': '%s/?feed=%d' % (
+                self.application_url, feed.id)})
 
     @GET(r'^/fever/?$')
-    def fever(self):        
-        page_title = 'Fever Endpoint'
+    def fever(self):
         return self.respond_with_template('fever.html')
 
     @GET(r'^/cheatsheet/?$')
-    def about(self):        
+    def about(self):
         return self.respond_with_template('_cheatsheet.html', locals())
 
-
     @form(r'^/profile/?$')
-    @login_required    
-    def profile(self):        
-        
+    @login_required
+    def profile(self):
+
         form_message = ''
-        user         = self.user
+        user = self.user
+
+        # Collect editable fields
+        email = user.email
+        password = user.password
 
-        # Collect editable fields 
-        email        = user.email
-        password     = user.password
-        
         if self.request.method == 'POST':
             form = self.request.POST
 
             email = form.get('email', '')
             password = form.get('password', '')
-            
-            if User.validate_password(password):            
+
+            if User.validate_password(password):
                 user.email = email
                 user.password = password
-                user.save()            
+                user.save()
                 return self.respond_with_script('_modal_done.js')
             else:
-                form_message = u'ERROR Error, password is too short.'
-        
+                form_message = 'ERROR Error, password is too short.'
+
         return self.respond_with_template('_user_edit.html', locals())
-        
 
     # Template methods
-    
-    def respond_with_modal(self, url, title='', body='', button='Close', params=None):
+
+    def respond_with_modal(self, url, title='',
+                           body='', button='Close', params=None):
         namespace = {
             'url': url,
             'title': title,
             'body': body,
             'params': params if params else [],
             'button_text': button
-        }                    
+        }
         return self.respond_with_template('_modal_alert.html', namespace)
-    
+
     def respond_with_script(self, filename, view_namespace=None):
-        
-        response = self._respond(filename, 'application/javascript', view_namespace) 
 
-        # Pass along alert_message cookie in the case 
+        response = self._respond(filename, 'application/javascript',
+                                 view_namespace)
+
+        # Pass along alert_message cookie in the case
         #   we force a redirect within the script
         if self.alert_message:
             response.set_cookie('alert_message', self.alert_message)
-                                
+
         return response
 
     def respond_with_template(self, filename, view_namespace=None):
-        
+
         message = self.request.cookies.get('alert_message', '')
-        
-        namespace = {'alert_message' : message}
+
+        namespace = {'alert_message': message}
         namespace.update(view_namespace or {})
-        
+
         response = self._respond(filename, 'text/html', namespace)
-        
+
         # Delete alert_message cookie, if any
         if message:
             response.delete_cookie('alert_message')
-                                
+
         return response
 
     def _respond(self, filename, content_type, view_namespace=None):
-        
+
         namespace = self.app_namespace.copy()
         namespace.update({
-            'request'           : self.request,
-            'application_url'   : self.application_url,
+            'request': self.request,
+            'application_url': self.application_url,
         })
 
         namespace.update(view_namespace or {})
-        
+
         if 'self' in namespace:
-             # Avoid passing self or Tempita will complain
+            # Avoid passing self or Tempita will complain
             del namespace['self']
 
         response = Response(
             _render_template(filename, namespace),
             content_type=content_type, charset='utf-8')
-                                        
+
         return response
-        
+
     def _redirect(self, klass, location):
         '''
-        Return a temporary or permament redirect response object. 
-          Caller may return it or raise it.
+        Return a temporary or permament redirect response object.
+        Caller may return it or raise it.
         '''
         response = klass(location=location)
         if self.alert_message:
@@ -545,15 +615,15 @@ def _redirect(self, klass, location):
 
     def redirect(self, location):
         '''
-        Return a temporary redirect response object. 
+        Return a temporary redirect response object.
           Caller may return it or raise it.
         '''
-        
+
         return self._redirect(HTTPTemporaryRedirect, location)
 
     def redirect_after_post(self, location):
         '''
-        Return a 'see other' redirect response object. 
+        Return a 'see other' redirect response object.
           Caller may return it or raise it.
         '''
         return self._redirect(HTTPSeeOther, location)
@@ -567,68 +637,70 @@ def user(self):
     @user.setter
     def user(self, user):
         self.session[USER_SESSION_KEY] = user
-                
+
     @form(r'^/login/?$')
     def login(self):
-
         form = self.request.params
 
-        username = form.get('username', '')        
+        username = form.get('username', '')
         password = form.get('password', '')
-        from_url = form.get('from', self.application_url)   
-                    
+        from_url = form.get('from', self.application_url)
+
         if self.request.method == 'POST':
             user = User.validate_credentials(username, password)
             if user:
                 self.user = user
                 return self.redirect_after_post(from_url)
             else:
-                self.alert_message = 'ERROR Unable to log in. Check your username, email and password.'            
+                self.alert_message = (
+                    'ERROR Unable to log in. '
+                    'Check your username, email and password.')
                 return self.redirect_after_post(self.request.url)
-        
+
         page_title = 'Log In'
         d = locals()
         d.update(get_stats())
-                
+
         return self.respond_with_template('login.html', d)
 
     @GET(r'^/logout/?$')
     def logout(self):
         response = self.redirect(self.application_url)
         response.delete_cookie(COOKIE_SESSION_KEY)
-        return response 
+        return response
 
 
 def setup_app():
     return SessionMiddleware(FrontendApp(), fieldname=COOKIE_SESSION_KEY)
 
-              
-#@@TODO: use utilities.render_template - see http://bit.ly/P5Hh5m
-def _render_template(filename, namespace):                    
-    return Template.from_filename(os.path.join(template_dir, filename), namespace=namespace).substitute()
+
+# @@TODO: use utilities.render_template - see http://bit.ly/P5Hh5m
+def _render_template(filename, namespace):
+    return Template.from_filename(os.path.join(template_dir, filename),
+                                  namespace=namespace,
+                                  encoding='utf-8').substitute()
 
 # Stats
 
+
 def get_stats():
     '''
-    Get some user-agnostic stats from Coldsweat database 
+    Get some user-agnostic stats from Coldsweat database
     '''
-    
+
     now = datetime.utcnow()
-    
-    last_checked_on = Feed.select().aggregate(fn.Max(Feed.last_checked_on))
+    last_checked_on = Feed.select(
+        fn.Max(Feed.last_checked_on)).get().get_or_none()
     if last_checked_on:
-        last_checked_on = format_datetime(last_checked_on)
+        last_checked_on = format_datetime(last_checked_on.last_updated_on)
     else:
         last_checked_on = 'Never'
 
-    entry_count = Entry.select().count()        
-    unread_entry_count = Entry.select().where(~(Entry.id << Read.select(Read.entry))).count()
+    entry_count = Entry.select().count()
+    unread_entry_count = Entry.select().where(
+        ~(Entry.id << Read.select(Read.entry))).count()
     feed_count = Feed.select().count()
-    #@@TODO: count enabled feeds with at least one subscriber
-    active_feed_count = Feed.select().where(Feed.is_enabled==True).count()
+    # @@TODO: count enabled feeds with at least one subscriber
+    active_feed_count = Feed.select().where(Feed.is_enabled==True).count()  # noqa
 
     return locals()
-
-
-
diff --git a/coldsweat/markup.py b/coldsweat/markup.py
index eee4c92..9ce1a15 100644
--- a/coldsweat/markup.py
+++ b/coldsweat/markup.py
@@ -7,11 +7,11 @@
 Portions are copyright (c) 2002–4 Mark Pilgrim
 """
 
+from html.parser import HTMLParser
+import urllib.parse as urlparse
 
-from HTMLParser import HTMLParser, HTMLParseError
-import urlparse
 
-from filters import escape_html
+from . filters import escape_html
 from coldsweat import logger
 
 HTML_RESERVED_CHARREFS = 38, 60, 62, 34
@@ -22,7 +22,8 @@ def _normalize_attrs(attrs):
     '''
     Normalize certain attribute values
     '''
-    return [(k, v.lower().strip() if k in ('rel', 'type') else v) for k, v in attrs]
+    return [(k, v.lower().strip() if k in ('rel', 'type') else v)
+            for k, v in attrs]
 
 
 class BaseParser(HTMLParser):
@@ -50,62 +51,68 @@ def unknown_endtag(self, tag):
 
 class BaseProcessor(BaseParser):
     '''
-    Parse and partially reconstruct the input document     
+    Parse and partially reconstruct the input document
     '''
 
-    # HTML 5 only    
-    void_elements = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 
-      'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' ]
-      
+    # HTML 5 only
+    void_elements = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr',
+                     'img', 'input', 'keygen', 'link', 'meta', 'param',
+                     'source', 'track', 'wbr']
+
     def __init__(self, xhtml_mode=False):
         BaseParser.__init__(self)
         self.xhtml_mode = xhtml_mode
 
-    #@@NOTE: reset is called implicitly by base class
-        
+    # @@NOTE: reset is called implicitly by base class
+
     def reset(self):
         BaseParser.reset(self)
         self.pieces = []
-        
+
     def output(self):
         '''
         Return processed HTML as a single string
         '''
-        return ''.join(self.pieces)    
-        
+        return ''.join(self.pieces)
+
     def unknown_starttag(self, tag, attrs):
-        # Called for each unhandled tag, where attrs is a list of (attr, value) tuples
-        #   e.g. for <pre class="screen">, tag="pre", attrs=[("class", "screen")]
-        #   The attr name will be translated to lower case, and quotes in the  
-        #   value have been removed and character and entity references 
-        #   have been replaced. Starting Python 2.6 all entity references from 
-        #   htmlentitydefs are replaced in the attribute values                    
-        s = "".join([' %s="%s"' % (key, escape_html(value)) for key, value in attrs])
+        # Called for each unhandled tag, where attrs is a list of
+        # (attr, value) tuples
+        #   e.g. for <pre class="screen">, tag="pre", attrs=[("class",
+        #                                                     "screen")]
+        #   The attr name will be translated to lower case, and quotes in the
+        #   value have been removed and character and entity references
+        #   have been replaced. Starting Python 2.6 all entity references from
+        #   htmlentitydefs are replaced in the attribute values
+        s = "".join([' %s="%s"' % (key, escape_html(value)) for key, value in
+                     attrs])
         if self.xhtml_mode and (tag in self.void_elements):
             self.pieces.append("<%s%s />" % (tag, s))
         else:
             self.pieces.append("<%s%s>" % (tag, s))
-        
+
     def unknown_endtag(self, tag):
         # Called for each unhandled end tag, e.g. for </pre>, tag will be "pre"
         #   Reconstruct the original end tag.
         if tag not in self.void_elements:
             self.pieces.append("</%s>" % tag)
-            
+
     def handle_charref(self, ref):
-        # Called for each character reference, e.g. for "&#160;", ref will be "160"
+        # Called for each character reference, e.g. for "&#160;", ref will be
+        # "160"
         #   Unescape the original character reference if not reserved
-        if ref in HTML_RESERVED_CHARREFS: 
-            self.pieces.append("&#%s;" % ref) 
+        if ref in HTML_RESERVED_CHARREFS:
+            self.pieces.append("&#%s;" % ref)
         else:
             self.pieces.append(self.unescape("&#%s;" % ref))
 
     def handle_entityref(self, ref):
-        # Called for each entity reference, e.g. for "&copy;", ref will be "copy"
+        # Called for each entity reference, e.g. for "&copy;", ref will be
+        # "copy"
         #   Unescape the original entity reference if not reserved
         if ref in HTML_RESERVED_ENTITIES:
-            self.pieces.append("&%s;" % ref)             
-        else:                        
+            self.pieces.append("&%s;" % ref)
+        else:
             self.pieces.append(self.unescape("&%s;" % ref))
 
     def handle_data(self, text):
@@ -114,17 +121,17 @@ def handle_data(self, text):
         #   Store the original text verbatim
         self.pieces.append(text)
 
-    # The following elements will be stripped by 
+    # The following elements will be stripped by
     #   the default implementation
 
     def handle_comment(self, text):
-        pass # Strip comments
+        pass  # Strip comments
 
     def handle_pi(self, text):
-        pass # Strip pi
+        pass  # Strip pi
 
     def handle_decl(self, text):
-        pass # Strip doctype declaration
+        pass  # Strip doctype declaration
 
 
 class Stripper(BaseProcessor):
@@ -140,13 +147,14 @@ def handle_charref(self, ref):
 
     def handle_entityref(self, ref):
         self.pieces.append(self.unescape("&%s;" % ref))
-                    
+
+
 class FeedLinkFinder(BaseParser):
     '''
-    Find the feeds for a web page. Code derived from Feedfinder, 
+    Find the feeds for a web page. Code derived from Feedfinder,
       see: http://bit.ly/6dBgUf
     '''
-    
+
     FEED_TYPES = [
         'application/rss+xml',
         'text/xml',
@@ -158,19 +166,18 @@ class FeedLinkFinder(BaseParser):
     def __init__(self, base_url=''):
         BaseParser.__init__(self)
         self.links = []
-        self.base_url= base_url
+        self.base_url = base_url
 
-    
     def start_base(self, attrs):
         d = dict(attrs)
         if 'href' not in d:
             return
         # Override passed base URL
         self.base_url = d['href']
-    
-    def start_link(self, attrs):                
+
+    def start_link(self, attrs):
         d = dict(_normalize_attrs(attrs))
-        if 'rel' not in d: 
+        if 'rel' not in d:
             return
         rels = d['rel'].split()
         if 'alternate' not in rels:
@@ -180,10 +187,11 @@ def start_link(self, attrs):
         if 'href' not in d:
             return
 
-        url, title = urlparse.urljoin(self.base_url, d['href']), d['title'] if 'title' in d else u''
+        url, title = urlparse.urljoin(
+            self.base_url, d['href']), d['title'] if 'title' in d else u''
         self.links.append((url, title))
 
- 
+
 class Scrubber(BaseProcessor):
     '''
     Remove annoying ads in entry images and links.
@@ -191,17 +199,18 @@ class Scrubber(BaseProcessor):
 
     def __init__(self, blacklist):
         BaseProcessor.__init__(self)
-        self.blacklist, self.blacklisted = blacklist, 0   
-            
+        self.blacklist, self.blacklisted = blacklist, 0
+
     def start_a(self, attrs):
         d = dict(_normalize_attrs(attrs))
         if 'href' in d:
             if self.is_blacklisted(d['href']):
-                logger.debug(u'matched anchor with blacklisted href=%s' % d['href'])
+                logger.debug(
+                    u'matched anchor with blacklisted href=%s' % d['href'])
                 self.blacklisted += 1
                 return
-                
-        # Proceed to default handling        
+
+        # Proceed to default handling
         self.unknown_starttag('a', attrs)
 
     def end_a(self):
@@ -209,21 +218,22 @@ def end_a(self):
             self.blacklisted -= 1
         else:
             self.unknown_endtag('a')
-                
+
     def start_img(self, attrs):
         d = dict(_normalize_attrs(attrs))
-        if 'src' in d:        
+        if 'src' in d:
             if self.is_blacklisted(d['src']):
                 self.pieces.append(d['alt'] if 'alt' in d else '')
-                logger.debug(u'matched image with blacklisted src=%s' % d['src'])
+                logger.debug(
+                    u'matched image with blacklisted src=%s' % d['src'])
                 return
 
-        # Proceed to default handling        
+        # Proceed to default handling
         self.unknown_starttag('img', attrs)
-   
 
     def is_blacklisted(self, value):
-        schema, netloc, path, params, query, fragment = urlparse.urlparse(value)
+        schema, netloc, path, params, query, fragment \
+            = urlparse.urlparse(value)
 
         for site in self.blacklist:
             if site in netloc:
@@ -231,16 +241,17 @@ def is_blacklisted(self, value):
         return False
 
 
-def _parse(parser, data):    
+def _parse(parser, data):
     try:
-        parser.feed(data)    
-    except HTMLParseError, exc:
+        parser.feed(data)
+    except (HTMLParseError, AssertionError) as exc:
         # Log exception and raise it again
         logger.debug(u'could not parse markup (%s)' % exc.msg)
         raise exc
 
 # Link discovery functions
 
+
 def find_feed_links(data, base_url=''):
     '''
     Return the feed links found for the page
@@ -249,23 +260,26 @@ def find_feed_links(data, base_url=''):
     _parse(p, data)
     return p.links
 
-def sniff_feed(data): 
+
+def sniff_feed(data):
     data = data.lower()
     if data.count('<html'):
         return False
     return any((data.count('<rss'), data.count('<rdf'), data.count('<feed')))
 
 # Misc.
-        
+
+
 def strip_html(data):
     '''
-    Strip all HTML tags and convert all entities/charrefs, effectively 
+    Strip all HTML tags and convert all entities/charrefs, effectively
       creating a plain text version of the input document
     '''
     p = Stripper()
     _parse(p, data)
     return p.output()
 
+
 def scrub_html(data, blacklist):
     '''
     Remove blacklisted links and images from the input document
@@ -273,6 +287,3 @@ def scrub_html(data, blacklist):
     p = Scrubber(blacklist)
     _parse(p, data)
     return p.output()
-
-
-
diff --git a/coldsweat/models.py b/coldsweat/models.py
index 47241d7..f71b1f7 100644
--- a/coldsweat/models.py
+++ b/coldsweat/models.py
@@ -6,17 +6,23 @@
 Portions are copyright (c) 2013 Rui Carmo
 License: MIT (see LICENSE for details)
 """
-import urlparse 
+import urllib.parse as urlparse
+
 import pickle
+
 from datetime import datetime
-from peewee import *
-from playhouse.migrate import *
-from playhouse.signals import Model as BaseModel, pre_save
-from playhouse.reflection import Introspector
-from webob.exc import status_map
 
-from coldsweat import *
-from utilities import *
+import peewee
+from playhouse.signals import Model, pre_save
+
+from peewee import (BlobField, BooleanField, CharField, DateTimeField,
+                    ForeignKeyField,
+                    IntegerField, IntegrityError,
+                    TextField, SqliteDatabase)
+from passlib import context
+
+from coldsweat import config, logger
+from .utilities import datetime_as_epoch, make_md5_hash, make_sha1_hash
 
 __all__ = [
     'User',
@@ -31,7 +37,6 @@
     'close',
     'transaction',
     'setup_database_schema',
-    'migrate_database_schema',
 ]
 
 # Feed default icon
@@ -45,9 +50,6 @@
 a/qy5uunENXcFW38XGAr8KKpl/TD6wNqn/XUqKZxX+mor42gB0XtoQ33LtnOS3p3AdYux\
 DfHjCbUKnl6OZTgAEAR+pHH9rWoLkAAAAASUVORK5CYII="
 
-class SqliteDatabase_(SqliteDatabase):
-    def initialize_connection(self, connection):
-        self.execute_sql('PRAGMA foreign_keys=ON;')
 
 def parse_connection_url(url):
     parsed = urlparse.urlparse(url, scheme='sqlite')
@@ -60,34 +62,42 @@ def parse_connection_url(url):
         connect_kwargs['host'] = parsed.hostname
     if parsed.port:
         connect_kwargs['port'] = parsed.port
-    
+
     # Adjust parameters for MySQL
     if parsed.scheme == 'mysql' and 'password' in connect_kwargs:
         connect_kwargs['passwd'] = connect_kwargs.pop('password')
-    
+
     return parsed.scheme, connect_kwargs
-    
+
 
 engine, kwargs = parse_connection_url(config.database.connection_url)
+
 if engine == 'sqlite':
-    _db = SqliteDatabase_(journal_mode='WAL', **kwargs) 
-    migrator = SqliteMigrator(_db)
-elif engine == 'mysql':
-    _db = MySQLDatabase(**kwargs)
-    migrator = MySQLMigrator(_db)
-elif engine == 'postgresql':
-    _db = PostgresqlDatabase(autorollback=True, **kwargs)
-    migrator = PostgresqlMigrator(_db)
+    database = SqliteDatabase(pragmas={'journal_mode': 'wal',
+                                       'foreign_keys': 1},
+                              **kwargs)
+#
+# elif engine == 'mysql':
+#    _db = MySQLDatabase(**kwargs)
+#    migrator = MySQLMigrator(_db)
+# elif engine == 'postgresql':
+#    _db = PostgresqlDatabase(autorollback=True, **kwargs)
+#    migrator = PostgresqlMigrator(_db)
 else:
-    raise ValueError('Unknown database engine %s. Should be sqlite, postgresql or mysql' % engine)
+    raise ValueError(
+        'Unknown database engine %s. Should be sqlite, postgresql or mysql'
+        % engine)
 
 # ------------------------------------------------------
 # Custom fields
 # ------------------------------------------------------
 
+
 class PickleField(BlobField):
+
     def db_value(self, value):
-        return super(PickleField, self).db_value(pickle.dumps(value, 2)) # Use newer protocol 
+        return super(PickleField, self).db_value(pickle.dumps(value, 2))
+    # Use newer protocol
 
     def python_value(self, value):
         return pickle.loads(value)
@@ -96,179 +106,218 @@ def python_value(self, value):
 # Coldsweat models
 # ------------------------------------------------------
 
-class CustomModel(BaseModel):
+
+class BaseModel(Model):
     """
     Binds the same database to all models
     """
 
     class Meta:
-        database = _db
+        database = database
 
 
-class User(CustomModel):
+class User(BaseModel):
     """
     Coldsweat user
-    """    
-    DEFAULT_USERNAME = 'coldsweat' 
+    """
+
+    DEFAULT_USERNAME = 'coldsweat'
     MIN_PASSWORD_LENGTH = 8
 
-    username            = CharField(unique=True)
-    password            = CharField()  
-    email               = CharField(default='')
-    api_key             = CharField(unique=True)
-    is_enabled          = BooleanField(default=True) 
+    username = CharField(unique=True)
+    email = CharField(default='')
+    api_key = CharField(unique=True)
+    is_enabled = BooleanField(default=True)
+    password = BlobField(255)
+
+    pw_context = context.CryptContext(
+        schemes=['pbkdf2_sha512', 'sha512_crypt'],
+        default='pbkdf2_sha512'
+    )
+
+    def __repr__(self):
+        return "<%s|%s>" % (self.name, self.email)
 
     class Meta:
-        db_table = 'users'
-    
+        table_name = 'users'
+
     @staticmethod
     def make_api_key(email, password):
-        return make_md5_hash(u'%s:%s' % (email, password))
+        return make_md5_hash('%s:%s' % (email, password))
 
     @staticmethod
     def validate_api_key(api_key):
         try:
             # Clients may send api_key in uppercase, lower it
-            user = User.get((User.api_key == api_key.lower()) & 
-                (User.is_enabled == True))        
+            user = User.get((User.api_key == api_key.lower()) &
+                            (User.is_enabled == True))  # noqa
         except User.DoesNotExist:
             return None
 
         return user
 
+    def check_password(self, input_password):
+        passed = User.pw_context.verify(input_password, self.password)
+        if not passed:
+            return False
+
+        elif User.pw_context.identify(self.password) != User.pw_context.default_scheme():  # noqa
+            self.password = User.pw_context.hash(input_password)
+            self.save()
+            return True
+        else:
+            return True
+
     @staticmethod
-    def validate_credentials(username_or_email, password):        
+    def validate_credentials(username_or_email, password):
         '''Lookup for and existing username/e-mail combo and password'''
         try:
-            user = User.get(((User.username == username_or_email) | 
-                (User.email == username_or_email)) & 
-                (User.is_enabled == True))
+            user = User.get(((User.username == username_or_email) |
+                            (User.email == username_or_email)) &
+                            (User.is_enabled == True))  # noqa
         except User.DoesNotExist:
             return None
 
         # Do a case-sensitive compare
-        if user.password != password:
+        if not user.check_password(password):
             return None
 
         return user
-    
+
     @staticmethod
     def validate_password(password):
         return len(password) >= User.MIN_PASSWORD_LENGTH
-        
+
+    def hash_password(self, raw=False):
+        """
+        Set password for user with specified encryption scheme
+        """
+        # for the list of hash schemes see
+        # https://wiki2.dovecot.org/Authentication/PasswordSchemes
+        if raw:
+            self.password = self.password
+        else:
+            self.password = User.pw_context.hash(self.password)
+
+
 @pre_save(sender=User)
 def on_user_save(model, user, created):
-     user.api_key = User.make_api_key(user.email, user.password)
-          
+    user.api_key = User.make_api_key(user.email, user.password)
+    user.hash_password()
+
 
-#@@REMOVEME: We keep this only to make migrations work
-class Icon(CustomModel):
+# @@REMOVEME: We keep this only to make migrations work
+class Icon(BaseModel):
     """
     Feed (fav)icons, stored as data URIs
     """
-    data = TextField() 
+    data = TextField()
 
     class Meta:
-        db_table = 'icons'
-      
+        table_name = 'icons'
 
-class Group(CustomModel):
+
+class Group(BaseModel):
     """
     Feed group/folder
     """
     DEFAULT_GROUP = 'Default'
-    
+
     title = CharField(unique=True)
-    
-    class Meta:  
+
+    class Meta:
         order_by = ('title',)
-        db_table = 'groups'    
+        table_name = 'groups'
 
 
-class Feed(CustomModel):
+class Feed(BaseModel):
     """
     Atom/RSS feed
     """
 
-    DEFAULT_ICON         = _ICON
-    MAX_TITLE_LENGTH     = 255
-    
-    is_enabled           = BooleanField(default=True)           # Fetch feed?
-    self_link            = TextField()                          # The URL of the feed itself (rel=self)
-    self_link_hash       = CharField(unique=True, max_length=40)
-    error_count          = IntegerField(default=0)
+    DEFAULT_ICON = _ICON
+    MAX_TITLE_LENGTH = 255
+
+    is_enabled = BooleanField(default=True)           # Fetch feed?
+    self_link = TextField()   # The URL of the feed itself (rel=self)
+    self_link_hash = CharField(unique=True, max_length=40)
+    error_count = IntegerField(default=0)
 
     # Nullable
 
-    title                = CharField(null=True)        
-    alternate_link       = TextField(null=True)                 # The URL of the HTML page associated with the feed (rel=alternate)
-    etag                 = CharField(null=True)                 # HTTP E-tag
-    last_updated_on      = DateTimeField(null=True)             # As UTC
-    last_checked_on      = DateTimeField(index=True, null=True) # As UTC 
-    last_status          = IntegerField(null=True)              # Last returned HTTP code    
+    title = CharField(null=True)
+    alternate_link = TextField(null=True)
+    # The URL of the HTML page associated with the feed (rel=alternate)
+    etag = CharField(null=True)                 # HTTP E-tag
+    last_updated_on = DateTimeField(null=True)             # As UTC
+    last_checked_on = DateTimeField(index=True, null=True)  # As UTC
+    last_status = IntegerField(null=True)  # Last returned HTTP code
 
-    icon                 = TextField(null=True)                 # Stored as data URI
-    icon_last_updated_on = DateTimeField(null=True)             # As UTC
+    icon = TextField(null=True)  # Stored as data URI
+    icon_last_updated_on = DateTimeField(null=True)  # As UTC
 
     class Meta:
-        db_table = 'feeds'
+        table_name = 'feeds'
 
     @property
     def last_updated_on_as_epoch(self):
         # Never updated?
-        if self.last_updated_on: 
+        if self.last_updated_on:
             return datetime_as_epoch(self.last_updated_on)
-        return 0 
+        return 0
 
     @property
     def icon_or_default(self):
         return self.icon if self.icon else Feed.DEFAULT_ICON
 
+
 @pre_save(sender=Feed)
 def on_feed_save(model, feed, created):
-     feed.self_link_hash = make_sha1_hash(feed.self_link)       
-      
-        
-class Entry(CustomModel):
+    feed.self_link_hash = make_sha1_hash(feed.self_link)
+
+
+class Entry(BaseModel):
     """
     Atom/RSS entry
     """
 
-    MAX_TITLE_LENGTH    = 255
+    MAX_TITLE_LENGTH = 255
 
-    guid                = TextField()                           # 'id' in Atom parlance
-    guid_hash           = CharField(unique=True, max_length=40)   
-    feed                = ForeignKeyField(Feed, on_delete='CASCADE')
-    title               = CharField()    
-    content_type        = CharField(default='text/html')
-    content             = TextField()
-    #@@TODO: rename to published_on
-    last_updated_on     = DateTimeField()                       # As UTC
+    guid = TextField()  # 'id' in Atom parlance
+    guid_hash = CharField(unique=True, max_length=40)
+    feed = ForeignKeyField(Feed, on_delete='CASCADE')
+    title = CharField()
+    content_type = CharField(default='text/html')
+    content = TextField()
+    # @@TODO: rename to published_on
+    last_updated_on = DateTimeField()  # As UTC
 
     # Nullable
 
-    author              = CharField(null=True)
-    link                = TextField(null=True)                  # If null the entry *must* provide a GUID
-    
+    author = CharField(null=True)
+    link = TextField(null=True)
+    # If null the entry *must* provide a GUID
+
     class Meta:
-        db_table = 'entries'
+        table_name = 'entries'
 
     @property
     def last_updated_on_as_epoch(self):
         return datetime_as_epoch(self.last_updated_on)
 
+
 @pre_save(sender=Entry)
 def on_entry_save(model, entry, created):
-    entry.guid_hash = make_sha1_hash(entry.guid)    
+    entry.guid_hash = make_sha1_hash(entry.guid)
 
-                
-class Saved(CustomModel):
+
+class Saved(BaseModel):
     """
-    Entries 'saved' status 
+    Entries 'saved' status
     """
-    user            = ForeignKeyField(User)
-    entry           = ForeignKeyField(Entry, on_delete='CASCADE')    
-    saved_on        = DateTimeField(default=datetime.utcnow)  
+    user = ForeignKeyField(User)
+    entry = ForeignKeyField(Entry, on_delete='CASCADE')
+    saved_on = DateTimeField(default=datetime.utcnow)
 
     class Meta:
         indexes = (
@@ -276,13 +325,13 @@ class Meta:
         )
 
 
-class Read(CustomModel):
+class Read(BaseModel):
     """
-    Entries 'read' status 
+    Entries 'read' status
     """
-    user           = ForeignKeyField(User)
-    entry          = ForeignKeyField(Entry, on_delete='CASCADE')    
-    read_on        = DateTimeField(default=datetime.utcnow) 
+    user = ForeignKeyField(User)
+    entry = ForeignKeyField(Entry, on_delete='CASCADE')
+    read_on = DateTimeField(default=datetime.utcnow)
 
     class Meta:
         indexes = (
@@ -290,31 +339,31 @@ class Meta:
         )
 
 
-class Subscription(CustomModel):
+class Subscription(BaseModel):
     """
     A user's feed subscription
     """
-    user           = ForeignKeyField(User)
-    group          = ForeignKeyField(Group, on_delete='CASCADE')
-    feed           = ForeignKeyField(Feed, on_delete='CASCADE')
+    user = ForeignKeyField(User)
+    group = ForeignKeyField(Group, on_delete='CASCADE')
+    feed = ForeignKeyField(Feed, on_delete='CASCADE')
 
     class Meta:
         indexes = (
             (('user', 'group', 'feed'), True),
-        )    
-        db_table = 'subscriptions'
+        )
+        table_name = 'subscriptions'
 
 
-class Session(CustomModel):
+class Session(BaseModel):
     """
     Web session
-    """    
-    key             = CharField(unique=True)
-    value           = PickleField()     
-    expires_on      = DateTimeField()
+    """
+    key = CharField(unique=True)
+    value = PickleField()
+    expires_on = DateTimeField()
 
     class Meta:
-        db_table = 'sessions' 
+        table_name = 'sessions'
 
 
 # ------------------------------------------------------
@@ -323,131 +372,33 @@ class Meta:
 
 def connect():
     logger.debug('opening connection')
-    _db.connect()
+    if peewee.__version__.split('.')[0] != '2':
+        database.connect(reuse_if_open=True)
+    else:
+        database.connect()
+
 
 def transaction():
-    return _db.transaction()
+    return database.transaction()
+
 
 def close():
-    if not _db.is_closed():
+    if not database.is_closed():
         logger.debug('closing connection')
-        _db.close()
-
-def migrate_database_schema():
-    '''
-    Migrate database schema from previous versions (0.9.4 and up)
-    '''
-
-    introspector = Introspector.from_database(_db)
-    models = introspector.generate_models()
-    Feed_ = models['feeds']
-    Entry_ = models['entries']
-
-    drop_table_migrations, column_migrations = [], []
-    
-    # --------------------------------------------------------------------------
-    # Schema changes introduced in version 0.9.4
-    # --------------------------------------------------------------------------
-    
-    # Change columns
-
-    if hasattr(Feed_, 'icon_id'):
-        column_migrations.append(migrator.drop_column('feeds', 'icon_id'))
-
-    if not hasattr(Feed_, 'icon'):
-        column_migrations.append(migrator.add_column('feeds', 'icon', Feed.icon))
-
-    if not hasattr(Feed_, 'icon_last_updated_on'):
-        column_migrations.append(migrator.add_column('feeds', 'icon_last_updated_on', Feed.icon_last_updated_on))
-        
-    if not hasattr(Entry_, 'content_type'):
-        column_migrations.append(migrator.add_column('entries', 'content_type', Entry.content_type))
-
-    # Drop tables
-
-    if Icon.table_exists():
-        drop_table_migrations.append(Icon.drop_table)
-
-    # --------------------------------------------------------------------------
-    # Schema changes introduced in version 0.9.5
-    # --------------------------------------------------------------------------
-    
-    # Change columns
-
-    class UpdateFeedSelfLinkHashOperation(object):
-        # Fake migrate.Operation protocol and upon saving populate all self_link_hash fields
-        def run(self):        
-            for feed in Feed.select():
-                feed.save()
-
-    class UpdateEntryGuidHashOperation(object):
-        def run(self):        
-            for entry in Entry.select():
-                entry.save()
-
-    class UpdateUserApiKeyOperation(object):
-        def run(self):        
-            for user in User.select():
-                user.save()
-                
-    if not hasattr(Feed_, 'self_link_hash'):
-        # Start relaxing index constrains to cope with existing data...
-        self_link_hash = CharField(null=True, max_length=40)
-        column_migrations.append(migrator.add_column('feeds', 'self_link_hash', self_link_hash))
-        column_migrations.append(UpdateFeedSelfLinkHashOperation())
-        # ...and make them strict again
-        column_migrations.append(migrator.add_index('feeds', ('self_link_hash',), True))
-        
-    if not hasattr(Entry_, 'guid_hash'):
-        # Start relaxing index constrains to cope with existing data...    
-        guid_hash = CharField(null=True, max_length=40)
-        column_migrations.append(migrator.add_column('entries', 'guid_hash', guid_hash))
-        column_migrations.append(UpdateEntryGuidHashOperation())
-        # ...and make them strict again
-        column_migrations.append(migrator.add_index('entries', ('guid_hash',), True))
-
-    # Drop obsolete indices
-    
-    if Feed_.self_link.unique:
-        column_migrations.append(migrator.drop_index('feeds', 'feeds_self_link'))
-    
-    if Entry_.link.index:
-        column_migrations.append(migrator.drop_index('entries', 'entries_link'))
-
-    if Entry_.guid.index:
-        column_migrations.append(migrator.drop_index('entries', 'entries_guid'))        
-
-    # Misc.
-        
-    column_migrations.append(UpdateUserApiKeyOperation())
-        
-    # --------------------------------------------------------------------------
-    
-    # Run all table and column migrations
-
-    if column_migrations:
-        # Let caller to catch any OperationalError's
-        migrate(*column_migrations)        
-
-    for drop in drop_table_migrations:
-        drop()
-
-    # True if at least one is non-empty
-    return drop_table_migrations or column_migrations
+        database.close()
 
 
 def setup_database_schema():
     """
     Create database and tables for all models and setup bootstrap data
     """
-
-    models = User, Feed, Entry, Group, Read, Saved, Subscription, Session
-
-    for model in models:
-        model.create_table(fail_silently=True)
+    with database:
+        database.create_tables([User, Feed, Entry, Group, Read, Saved,
+                                Subscription, Session],
+                               safe=True)
 
     # Create the bare minimum to bootstrap system
     try:
-        Group.create(title=Group.DEFAULT_GROUP)        
+        Group.create(title=Group.DEFAULT_GROUP)
     except IntegrityError:
         return
diff --git a/coldsweat/plugins.py b/coldsweat/plugins.py
index 96cbe0b..0c53734 100644
--- a/coldsweat/plugins.py
+++ b/coldsweat/plugins.py
@@ -9,7 +9,7 @@
 
 import imp
 
-from coldsweat import *
+from coldsweat import config, plugin_dir, logger
 
 # Define an informal API for plugin implementations
 
@@ -17,6 +17,7 @@
     'event',
 ]
 
+
 def event(name):
     def _(handler):
         FETCHER_EVENTS[name].append(handler)
@@ -25,29 +26,33 @@ def _(handler):
 
 # These are used internally by Coldsweat
 
+
 FETCHER_EVENTS = {}
+
 for name in 'entry_parsed fetch_started fetch_done'.split():
     FETCHER_EVENTS[name] = []
 
+
 def trigger_event(name, *args):
     for handler in FETCHER_EVENTS[name]:
         handler(*args)
-        
+
+
 def load_plugins():
     '''
     Load plugins listed in config file
     '''
     if not config.plugins.load:
         return
-        
+
     for name in config.plugins.load.split(','):
         name = name.strip()
         try:
             fp, pathname, description = imp.find_module(name, [plugin_dir])
             imp.load_module(name, fp, pathname, description)
-        except ImportError, ex:
+        except ImportError as ex:
             logger.warn(u'could not load %s plugin (%s), ignored' % (name, ex))
             continue
-        
+
         logger.debug('loaded %s plugin' % name)
         fp.close()
diff --git a/coldsweat/session.py b/coldsweat/session.py
index 1a204bb..05eb4b6 100644
--- a/coldsweat/session.py
+++ b/coldsweat/session.py
@@ -32,19 +32,27 @@
 SUCH DAMAGE.
 """
 
-import sys, os, string, threading, atexit, random, weakref
+
+import atexit
+import random
+import sys
+import string
+import threading
+import weakref
+
 from datetime import datetime, timedelta
-from Cookie import SimpleCookie
+from http.cookies import SimpleCookie
 
-from utilities import make_sha1_hash
-from models import Session, connect, close
+from .utilities import make_sha1_hash
+from .models import Session, connect, close
 from coldsweat import logger
 
 __all__ = [
     'SessionMiddleware',
 ]
 
-SESSION_TIMEOUT = 60*60*24*30 # 1 month
+SESSION_TIMEOUT = 60*60*24*30  # 1 month
+
 
 def synchronized(func):
     def wrapper(self, *__args, **__kw):
@@ -66,14 +74,14 @@ class SessionMiddleware(object):
 
     def __init__(self, app, **kwargs):
         self.app = app
-        self.kwargs = kwargs # Pass everything else to SessionManager
+        self.kwargs = kwargs  # Pass everything else to SessionManager
 
     def __call__(self, environ, start_response):
         connect()
-        
+
         # New session manager instance each time
         manager = SessionManager(environ, **self.kwargs)
-        # Add a session object to wrapped app        
+        # Add a session object to wrapped app
         self.app.session = manager.session
 
         # Initial response to a cookie session
@@ -85,27 +93,28 @@ def session_response(status, headers, exc_info=None):
 
         try:
             # Return initial response if new or session id is random
-            if manager.is_new: 
+            if manager.is_new:
                 return initial_response(environ, start_response)
             return self.app(environ, start_response)
         finally:
-            # Always close session and database connection 
+            # Always close session and database connection
             manager.close()
             close()
 
+
 class SessionManager(object):
- 
-    def __init__(self, environ, fieldname, path='/'):   
+
+    def __init__(self, environ, fieldname, path='/'):
         self._cache = SessionCache()
         self._fieldname = fieldname
         self._path = path
-        
+
         self.session = self._sid = None
         self.is_new = False
-        
+
         self._get(environ)
 
-    def _get(self, environ):  
+    def _get(self, environ):
         '''
         Attempt to associate with an existing session
         '''
@@ -129,24 +138,26 @@ def set_cookie(self, headers):
         Sets a session cookie header if needed
         '''
         cookie, name = SimpleCookie(), self._fieldname
-        cookie[name], cookie[name]['path'],  cookie[name]['max-age'] = self._sid, self._path, SESSION_TIMEOUT
+        cookie[name], cookie[name]['path'],  cookie[name]['max-age'] = \
+            self._sid, self._path, SESSION_TIMEOUT
         headers.append(('Set-Cookie', cookie[name].OutputString()))
 
     def delete_cookie(self, headers):
         pass
 
-    def _from_cookie(self, environ): 
+    def _from_cookie(self, environ):
         '''
-        Attempt to load the associated session using the identifier from the cookie
+        Attempt to load the associated session using the identifier from the
+        cookie
         '''
-        #@@TODO: Use Webob.request
-        
+        # @@TODO: Use Webob.request
+
         cookie = SimpleCookie(environ.get('HTTP_COOKIE'))
         morsel = cookie.get(self._fieldname, None)
         if morsel:
             self._sid, self.session = self._cache.checkout(morsel.value)
             cookie_sid = morsel.value
-            if cookie_sid != self._sid: 
+            if cookie_sid != self._sid:
                 self.is_new = True
 
 
@@ -154,13 +165,13 @@ def _shutdown(ref):
     cache = ref()
     if cache:
         cache.shutdown()
-            
+
 
 class SessionCache(object):
     '''
-    You first acquire a session by calling create() or checkout(). After 
-    using the session, you must call checkin(). You must not keep references 
-    to sessions outside of a check in/check out block. Always obtain a fresh 
+    You first acquire a session by calling create() or checkout(). After
+    using the session, you must call checkin(). You must not keep references
+    to sessions outside of a check in/check out block. Always obtain a fresh
     reference
     '''
     # Would be nice if len(idchars) were some power of 2
@@ -169,15 +180,14 @@ class SessionCache(object):
 
     def __init__(self, is_random=False):
         self._lock = threading.Condition()
-        self.checkedout, self._closed  = dict(), False
+        self.checkedout, self._closed = dict(), False
         # Sets if session id is random on every access or not
         self.is_random = is_random
-        self._secret = ''.join(self.idchars[ord(c) % len(self.idchars)]
-            for c in os.urandom(self.length))
+        self._secret = ''.join(random.SystemRandom().choice(self.idchars)
+                               for _ in range(self.length))
         # Ensure shutdown is called.
         atexit.register(_shutdown, weakref.ref(self))
 
-
     def __del__(self):
         self.shutdown()
 
@@ -208,7 +218,7 @@ def checkout(self, sid):
         # If we know it's already checked out, block.
         while sid in self.checkedout:
             self._lock.wait()
-        
+
         session = get_session(sid)
         if session:
             # Randomize session id if requested and remove old session id
@@ -240,8 +250,7 @@ def shutdown(self):
             for sid, value in self.checkedout.items():
                 set_session(sid, value)
             self.checkedout.clear()
-            #self.cache._cull()
-            self._closed = True        
+            self._closed = True
 
     # Utilities
 
@@ -250,36 +259,42 @@ def get_new_id(self):
         Returns a session key that is not being used
         '''
         sid = None
-        for _ in xrange(10000):
-            a = random.randint(0, sys.maxint-1)
-            b = random.randint(0, sys.maxint-1)            
+        for _ in range(10000):
+            try:
+                a = random.randint(0, sys.maxint-1)
+                b = random.randint(0, sys.maxint-1)
+            except AttributeError:
+                a = random.randint(0, sys.maxsize-1)
+                b = random.randint(0, sys.maxsize-1)
+
             sid = make_sha1_hash('%s%s%s' % (a, b, self._secret))
-            # Dupe? 
+            # Dupe?
             if not get_session(sid):
                 break
         return sid
-        
+
 # --------------------------------------
-# Model interface 
+# Model interface
 # --------------------------------------
 
+
 def get_session(sid, default=None):
     try:
-        session = Session.get(Session.key==sid)
+        session = Session.get(Session.key == sid)
     except Session.DoesNotExist:
         return default
-    
+
     # Expired?
     if session.expires_on < datetime.utcnow().replace(microsecond=0):
         session.delete_instance()
-        logger.debug(u"session %s is expired, deleted" % sid)
+        logger.debug("session %s is expired, deleted" % sid)
         return default
-    
+
     return session
 
 
 def delete_session(sid):
-    Session.delete().where(Session.key==sid).execute()
+    Session.delete().where(Session.key == sid).execute()
 
 
 def set_session(sid, value, timeout=SESSION_TIMEOUT):
@@ -288,8 +303,9 @@ def set_session(sid, value, timeout=SESSION_TIMEOUT):
     if not session:
         # New session if sid not present
         session = Session(key=sid)
-        logger.debug(u"session %s created" % sid)
+        logger.debug("session %s created" % sid)
 
-    session.expires_on = (datetime.utcnow() + timedelta(seconds=timeout)).replace(microsecond=0)
+    session.expires_on = (datetime.utcnow() + timedelta(
+                          seconds=timeout)).replace(microsecond=0)
     session.value = value
-    session.save()
\ No newline at end of file
+    session.save()
diff --git a/coldsweat/translators.py b/coldsweat/translators.py
index 39bb4c0..fe8d8b8 100644
--- a/coldsweat/translators.py
+++ b/coldsweat/translators.py
@@ -5,47 +5,47 @@
 Copyright (c) 2013—2016 Andrea Peltrin
 License: MIT (see LICENSE for details)
 '''
-#import sys, os, re, time, urlparse
+from coldsweat import logger
+from . utilities import tuple_as_datetime, scrub_url, truncate
 
-from coldsweat import *
-from utilities import *
+from . models import Feed, Entry
 
-from models import *
-
-import markup
+from . import markup
 
 __all__ = [
     'FeedTranslator',
     'EntryTranslator'
 ]
 
+
 class FeedTranslator(object):
 
     def __init__(self, feed_dict):
         self.feed_dict = feed_dict
-        
+
     def get_timestamp(self, default):
         for header in ['published_parsed', 'updated_parsed']:
             value = self.feed_dict.get(header, None)
             if value:
                 # Fix future dates
                 return min(tuple_as_datetime(value), default)
-        logger.debug(u'no feed timestamp found, using default')    
+        logger.debug(u'no feed timestamp found, using default')
         return default
 
     # Nullable fields
-    
+
     def get_author(self):
         if 'name' in self.feed_dict.get('author_detail', []):
             return self.feed_dict.author_detail.name
         return None
-    
-    def get_alternate_link(self):        
+
+    def get_alternate_link(self):
         return self.feed_dict.get('link', None)
-        
+
     def get_title(self):
         if 'title' in self.feed_dict:
-            return truncate(markup.strip_html(self.feed_dict.title), Feed.MAX_TITLE_LENGTH)
+            return truncate(markup.strip_html(self.feed_dict.title),
+                            Feed.MAX_TITLE_LENGTH)
         return None
 
 
@@ -53,11 +53,11 @@ class EntryTranslator(object):
 
     def __init__(self, entry_dict):
         self.entry_dict = entry_dict
-    
+
     def get_guid(self, default):
         """
         Get a useful GUID from a feed entry
-        """    
+        """
         value = getattr(self.entry_dict, 'id', None)
         return value or default
 
@@ -70,23 +70,24 @@ def get_timestamp(self, default):
             if value:
                 # Fix future dates
                 return min(tuple_as_datetime(value), default)
-        logger.debug(u'no entry timestamp found, using default')    
+        logger.debug(u'no entry timestamp found, using default')
         return default
-            
+
     def get_title(self, default):
         if 'title' in self.entry_dict:
-            return truncate(markup.strip_html(self.entry_dict.title), Entry.MAX_TITLE_LENGTH)
+            return truncate(markup.strip_html(self.entry_dict.title),
+                            Entry.MAX_TITLE_LENGTH)
         return default
 
     def get_source(self):
-#         d = self.entry_dict.get('source')
-#         d['link']      
+        #  d = self.entry_dict.get('source')
+        #  d['link']
         return ''
-    
+
     def get_content(self, default):
         """
         Select the best content from an entry
-        """    
+        """
         candidates = self.entry_dict.get('content', [])
         if 'summary_detail' in self.entry_dict:
             candidates.append(self.entry_dict.summary_detail)
@@ -97,12 +98,12 @@ def get_content(self, default):
         # Return first result, regardless of MIME type
         if candidates:
             return candidates[0].type, candidates[0].value
-    
-        logger.debug(u'no entry content found, using default')    
+
+        logger.debug(u'no entry content found, using default')
         return default
-        
+
     # Nullable fields
-    
+
     def get_link(self):
         # Special case for FeedBurner entries, see: http://bit.ly/1gRAvJv
         if 'feedburner_origlink' in self.entry_dict:
@@ -110,8 +111,8 @@ def get_link(self):
         if 'link' in self.entry_dict:
             return scrub_url(self.entry_dict.link)
         return None
-        
-    def get_author(self):  
-        if 'name' in self.entry_dict.get('author_detail',[]):
-            return self.entry_dict.author_detail.name     
+
+    def get_author(self):
+        if 'name' in self.entry_dict.get('author_detail', []):
+            return self.entry_dict.author_detail.name
         return None
diff --git a/coldsweat/utilities.py b/coldsweat/utilities.py
index ea54b43..0f20f8f 100644
--- a/coldsweat/utilities.py
+++ b/coldsweat/utilities.py
@@ -6,19 +6,26 @@
 Portions are copyright (c) 2013 Rui Carmo
 License: MIT (see LICENSE for details)
 """
-import os, re, cgi, urllib, urlparse
+import os
+import re
+
+from urllib.parse import urlparse
+import urllib.request, urllib.parse, urllib.error
+
 from hashlib import md5, sha1
 import base64
 from calendar import timegm
-from datetime import datetime, timedelta
+from datetime import datetime
 from tempita import HTMLTemplate
 
 # --------------------
 # String utilities
 # --------------------
 
+
 def encode(value):
-    return value.encode('utf-8', 'replace')    
+    return value.encode('utf-8', 'replace')
+
 
 def truncate(value, max_length):
     """
@@ -26,29 +33,33 @@ def truncate(value, max_length):
     """
     if len(value) < max_length:
         return value
-    return value[:max_length-1] + u'…'   
+    return value[:max_length-1] + '…'
+
 
 def make_data_uri(content_type, data):
     """
     Return data as a data:URI scheme
     """
-    return "data:%s;base64,%s" % (content_type, base64.standard_b64encode(data))
+    return "data:%s;base64,%s" % (content_type,
+                                  base64.standard_b64encode(data))
 
 
 # --------------------
 # Hash functions
 # --------------------
 
-def make_md5_hash(s):      
+def make_md5_hash(s):
     return md5(encode(s)).hexdigest()
 
-def make_sha1_hash(s):          
+
+def make_sha1_hash(s):
     return sha1(encode(s)).hexdigest()
 
+
 def make_nonce():
     try:
         nonce = os.urandom(16)
-    except NotImplementedError: 
+    except NotImplementedError:
         # urandom might not be available on certain platforms
         nonce = datetime.now().isoformat()
     return nonce.encode('base64')
@@ -57,52 +68,62 @@ def make_nonce():
 # URL utilities
 # --------------------
 
-BLACKLIST_QS = ["utm_source", "utm_campaign", "utm_medium", "utm_content", "utm_cid", "utm_term", "piwik_campaign", "piwik_kwd"]
-     
-# Lifted from https://github.com/django/django/blob/master/django/core/validators.py
+
+BLACKLIST_QS = ["utm_source", "utm_campaign", "utm_medium",
+                "utm_content", "utm_cid", "utm_term",
+                "piwik_campaign", "piwik_kwd"]
+
+# Lifted from https://github.com/django/django/\
+#        blob/master/django/core/validators.py
 RE_URL = re.compile(
-    r'^https?://'                           # http:// or https://
-    r'(?:[^:@/]+:[^:@/]+@)?'                # credentials
-    r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'  # domain...
+    r'^https?://'               # http:// or https://
+    r'(?:[^:@/]+:[^:@/]+@)?'    # credentials
+    r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'  # domain..
     r'localhost|'                           # localhost...
     r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'  # ...or ip
     r'(?::\d+)?'                            # optional port
-    r'(?:/?|[/?]\S+)$', re.IGNORECASE)    
-    #@@TODO: Add IPv6
-    
+    r'(?:/?|[/?]\S+)$', re.IGNORECASE)
+#  @@TODO: Add IPv6
+
 
 def validate_url(value):
     return value and RE_URL.search(value)
 
+
 def scrub_url(url):
     '''
     Clean query string arguments
     '''
-    scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
-    d = urlparse.parse_qs(query)
-    d = dict((k, v) for k, v in d.items() if k not in BLACKLIST_QS)
-    return urlparse.urlunsplit((scheme, netloc, path, urllib.urlencode(d, doseq=True), fragment))
-                
+    scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url)
+    d = urllib.parse.parse_qs(query)
+    d = dict((k, v) for k, v in list(d.items()) if k not in BLACKLIST_QS)
+    return urllib.parse.urlunsplit((scheme, netloc, path,
+                                urllib.parse.urlencode(d, doseq=True), fragment))
+
 # --------------------
 # Date/time functions
 # --------------------
 
+
 def datetime_as_epoch(value):
     return int(timegm(value.utctimetuple()))
 
+
 def tuple_as_datetime(value):
     return datetime.utcfromtimestamp(timegm(value))
 
+
 # Weekday and month names for HTTP date/time formatting; always English!
 _weekdayname = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
-_monthname = [None, # Dummy so we can use 1-based month numbers
+_monthname = [None,  # Dummy so we can use 1-based month numbers
               "Jan", "Feb", "Mar", "Apr", "May", "Jun",
               "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
 
+
 def format_http_datetime(value):
     """
-    Format datetime to comply with RFC 1123 
-    (ex.: Fri, 12 Feb 2010 16:23:03 GMT). 
+    Format datetime to comply with RFC 1123
+    (ex.: Fri, 12 Feb 2010 16:23:03 GMT).
     Assume GMT values
     """
     year, month, day, hh, mm, ss, wd, y, z = value.utctimetuple()
@@ -110,27 +131,31 @@ def format_http_datetime(value):
         _weekdayname[wd], day, _monthname[month], year, hh, mm, ss
     )
 
+
 def format_iso_datetime(value):
-    # Unlike datetime.isoformat() assume UTC 
+    # Unlike datetime.isoformat() assume UTC
     return format_datetime(value, format='%Y-%m-%dT%H:%M:%SZ')
-           
+
+
 def format_datetime(value, format='%a, %b %d at %H:%M'):
     return value.strftime(format)
 
+
 def format_date(value):
     return format_datetime(value, '%b %d, %Y')
 
+
 def datetime_since(value, comparison_value=None, default="just now"):
     """
     Returns string representing "time since" e.g.
     3 days ago, 5 hours ago etc.
-    
+
     From http://flask.pocoo.org/snippets/33/
     """
 
     comparison_value = comparison_value or datetime.utcnow()
     diff = comparison_value - value
-    
+
     periods = (
         (diff.days / 365, "year", "years"),
         (diff.days / 30, "month", "months"),
@@ -141,37 +166,39 @@ def datetime_since(value, comparison_value=None, default="just now"):
         (diff.seconds, "second", "seconds"),
     )
 
-    for period, singular, plural in periods:        
+    for period, singular, plural in periods:
         if period:
             return "%d %s ago" % (period, singular if period == 1 else plural)
 
     return default
 
+
 def datetime_since_today(value, comparison_value=None):
     """
     Returns string representing "date since" e.g.
     today, yesterday and Wed, Jan 29
     """
 
-    comparison_value = comparison_value or datetime.utcnow()    
-    delta = comparison_value - value    
-    if delta.days == 0:       
+    comparison_value = comparison_value or datetime.utcnow()
+    delta = comparison_value - value
+    if delta.days == 0:
         return 'today'
-    elif delta.days == 1: 
+    elif delta.days == 1:
         return 'yesterday'
-    
+
     # Earlier date
     return format_date(value)
-    
-    
+
+
 # --------------------
 # Misc.
 # --------------------
 
-# def render_template(filename, namespace):                    
-#     return HTMLTemplate.from_filename(filename, namespace=namespace).substitute()
+# def render_template(filename, namespace):
+#     return HTMLTemplate.from_filename(filename,
+#                                       namespace=namespace).substitute()
 
-def render_template(filename, namespace, filters_module=None):                    
+def render_template(filename, namespace, filters_module=None):
     # Install template filters if given
     if filters_module:
         filters_namespace = {}
@@ -179,21 +206,23 @@ def render_template(filename, namespace, filters_module=None):
             filter = getattr(filters_module, name)
             filters_namespace[filter.name] = filter
         # @@HACK Remove conflicting filter with HTMLTemplate
-        del filters_namespace['html'] 
+        del filters_namespace['html']
         # Update namespace, possibly overriding names
         namespace.update(filters_namespace)
-    return HTMLTemplate.from_filename(filename, namespace=namespace).substitute()
-        
+    return HTMLTemplate.from_filename(filename,
+                                      namespace=namespace).substitute()
+
+
 class Struct(dict):
     """
-    An object that recursively builds itself from a dict 
+    An object that recursively builds itself from a dict
     and allows easy access to attributes
     """
 
     def __init__(self, d=None):
         d = d or {}
         super(Struct, self).__init__(d)
-        for k, v in d.items():
+        for k, v in list(d.items()):
             if isinstance(v, dict):
                 self.__dict__[k] = Struct(v)
             else:
@@ -204,27 +233,33 @@ def __getattr__(self, attr):
             return self.__dict__[attr]
         except KeyError:
             raise AttributeError(attr)
-            
+
     def __setitem__(self, key, value):
         super(Struct, self).__setitem__(key, value)
         self.__dict__[key] = value
 
     def __setattr__(self, attr, value):
-        self.__setitem__(attr, value)            
+        self.__setitem__(attr, value)
+
 
-    
 def run_tests():
-    
-    t = datetime.utcnow()                
-    print format_http_datetime(t)
-    assert truncate(u'Lorèm ipsum dolor sit ame', 10) == u'Lorèm ips…'
-
-    assert scrub_url('http://example.org/feed.xml?utm_source=foo&utm_medium=bar&utm_content=baz&utm_campaign=qux') == 'http://example.org/feed.xml'     
-    assert scrub_url('http://example.org/feed.xml?a=1&a=2&b=1&utm_source=foo&utm_medium=bar&utm_content=baz&utm_campaign=qux') == 'http://example.org/feed.xml?a=1&a=2&b=1'
-    assert validate_url('https://user.name:password123@example.com/feed.xml')   # OK
-    assert validate_url('https://example.com')                                  # OK
-    assert validate_url('http://example.org/feed.xml')                          # OK
-    assert not validate_url('example.com')                                      # Fail
-    
+
+    t = datetime.utcnow()
+    print((format_http_datetime(t)))
+    assert truncate('Lorèm ipsum dolor sit ame', 10) == 'Lorèm ips…'
+
+    assert scrub_url(
+        'http://example.org/feed.xml?utm_source='
+        'foo&utm_medium=bar&utm_content=baz&utm_campaign=qux') == \
+        'http://example.org/feed.xml'
+    assert scrub_url('http://example.org/feed.xml?a=1&a=2&b='
+                     '1&utm_source=foo&utm_medium=bar&utm_content=baz'
+                     '&utm_campaign=qux') == 'http://example.org/feed.xml?a=1&a=2&b=1'  # noqa
+    assert validate_url('https://user.name:password123@example.com/feed.xml')
+    assert validate_url('https://example.com')
+    assert validate_url('http://example.org/feed.xml')
+    assert not validate_url('example.com')
+
+
 if __name__ == '__main__':
     run_tests()
diff --git a/dispatch.fcgi b/dispatch.fcgi
deleted file mode 100755
index 8beddcb..0000000
--- a/dispatch.fcgi
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-'''
-Description: bootstrap file for FastCGI environments
-
-Copyright (c) 2013—2016 Andrea Peltrin
-License: MIT (see LICENSE for details)
-'''
-
-try:
-    from flup.server.fcgi_fork import WSGIServer
-except ImportError, exc:
-    print 'Error: unable to import Flup package.\nColdsweat needs Flup to run as a FastCGI process.\nDownload it from PyPI: http://pypi.python.org/pypi/flup'
-    raise exc
-
-from coldsweat.app import setup_app
-
-if __name__ == '__main__':
-    WSGIServer(setup_app()).run()
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 0000000..14a3477
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,28 @@
+FROM alpine:3.10
+MAINTAINER  Oz Tiram <oz.tiram@gmail.com>
+
+RUN apk update && \
+    apk add py3-pip uwsgi uwsgi-python3 gnupg sqlite && \
+    pip3 install --upgrade pip && \
+    test -d /run/coldsweat || mkdir /run/coldsweat && \
+    test -d /var/lib/coldsweat || mkdir /var/lib/coldsweat && \
+    test -d /etc/coldsweat || mkdir /etc/coldsweat
+
+ADD requirements.txt /run/coldsweat/
+RUN pip install -r /run/coldsweat/requirements.txt
+
+RUN adduser -D coldsweat
+ADD docker/entrypoint.sh /usr/bin/
+ADD docker/config /etc/coldsweat/
+ADD sweat.py /run/coldsweat/
+ADD coldsweat /run/coldsweat/coldsweat
+ADD plugins /run/coldsweat/
+ADD wsgi.py /run/coldsweat/
+ADD static /run/coldsweat/static
+
+VOLUME ["/var/lib/coldsweat/db"]
+
+EXPOSE 9001
+USER coldsweat
+
+CMD ["/usr/bin/entrypoint.sh"]
diff --git a/docker/config b/docker/config
new file mode 100644
index 0000000..73eb3d2
--- /dev/null
+++ b/docker/config
@@ -0,0 +1,49 @@
+[database]
+
+connection_url: sqlite:////var/lib/coldsweat/db/coldsweat.db
+
+;connection_url: postgresql://username:password@localhost/coldsweat
+
+; Configuration settings below are all optional, default values are shown
+
+[log]
+
+; Values are: DEBUG (more verbose), INFO, WARNING, ERROR, CRITICAL (less verbose)
+;level: INFO
+
+; Comment to avoid logging to a file altogether
+
+[fetcher]
+
+; Minimun number of seconds allowed between each feed fetch
+;min_interval: 900
+
+; Number of errors before disabling the feed, with 0 the setting is ignored
+;max_errors: 50
+
+; Do not retrieve feed entries older than given days, with 0 the setting is ignored
+;max_history: 7
+
+; Number of seconds before timeout a web request
+;timeout: 10
+
+; Number of processes to spawn during the feed fetching, 0 disables multiprocessing
+;processes: 4
+
+[web]
+
+; Static files served from a different server
+;static_url: http://media.example.com/static
+
+[plugins]
+
+; Comma separated list of plugins to load
+;load: scrubber
+
+; Scubber plugin options
+
+; Comma-separated list of host names. Links and images in entries
+; with these host names will be scrubbed upon fetching. A partial
+; match is performed, e.g. the "feedsportal.com" line will match
+; both ad.feedsportal.com and feedsportal.com.
+scrubber_blacklist: feeds.feedburner.com,feedsportal.com,stats.wordpress.com
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100755
index 0000000..8e1777f
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+UWSGI_PORT="${UWSGI_PORT:-9001}"
+UWSGI_PROCS=${UWSGI_PROCS:-4}
+UWSGI_THREADS=${UWSGI_THREADS:-4}
+
+export COLDSWEAT_INSTALL_DIR=/run/coldsweat
+export COLDSWEAT_CONFIG_PATH=/etc/coldsweat/config
+
+chdir /run/coldsweat
+
+# if you want to use python's reference server ...
+# good for debugging, don't use in production
+#exec python3 sweat.py serve -p 9001
+
+exec uwsgi --need-app --plugins-dir /usr/lib/uwsgi  --need-plugin python3 \
+	--honour-stdin \
+	--pythonpath /run/coldsweat \
+	--plugin python3 --processes "${UWSGI_PROCS}" \
+	--threads "${UWSGI_THREADS}" --http-socket 0.0.0.0:"${UWSGI_PORT}" \
+	--static-map /static=/run/coldsweat/static/ \
+	--static-map /stylesheets=/run/coldsweat/static/stylesheets \
+	--static-map /javascripts/=/run/coldsweat/static/javascripts \
+	--static-map /fonts/=/run/coldsweat/static/fonts \
+	--wsgi-file=/run/coldsweat/wsgi.py --callable=app --master \
+	--log-master \
+	--logformat 'pid: %(pid)|app: -|req: -/-] %(var.HTTP_X_REAL_IP) (%(user)) {%(vars) vars in %(pktsize) bytes} [%(ctime)] %(method) %(uri) => generated %(rsize) bytes in %(msecs) msecs (%(proto) %(status)) %(headers) headers in %(hsize) bytes (%(switches) switches on core %(core))'
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..5dede4a
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,19 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+SOURCEDIR     = .
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..bb34842
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# Configuration file for the Sphinx documentation builder.
+#
+# This file does only contain a selection of the most common options. For a
+# full list see the documentation:
+# http://www.sphinx-doc.org/en/master/config
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = u'coldsweet'
+copyright = u'2019, Oz Tiram'
+author = u'Oz Tiram'
+
+# The short X.Y version
+version = u''
+# The full version, including alpha/beta/rc tags
+release = u'0.1'
+
+
+# -- General configuration ---------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.mathjax',
+    'sphinx.ext.ifconfig',
+    'sphinx.ext.viewcode',
+    'sphinx.ext.githubpages',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = [u'build', 'Thumbs.db', '.DS_Store']
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = None
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'alabaster'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['static']
+
+# Custom sidebar templates, must be a dictionary that maps document names
+# to template names.
+#
+# The default sidebars (for documents that don't match any pattern) are
+# defined by theme itself.  Builtin themes are using these templates by
+# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
+# 'searchbox.html']``.
+#
+# html_sidebars = {}
+
+
+# -- Options for HTMLHelp output ---------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'coldsweetdoc'
+
+
+# -- Options for LaTeX output ------------------------------------------------
+
+latex_elements = {
+    # The paper size ('letterpaper' or 'a4paper').
+    #
+    # 'papersize': 'letterpaper',
+
+    # The font size ('10pt', '11pt' or '12pt').
+    #
+    # 'pointsize': '10pt',
+
+    # Additional stuff for the LaTeX preamble.
+    #
+    # 'preamble': '',
+
+    # Latex figure (float) alignment
+    #
+    # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+    (master_doc, 'coldsweet.tex', u'coldsweet Documentation',
+     u'Oz Tiram', 'manual'),
+]
+
+
+# -- Options for manual page output ------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    (master_doc, 'coldsweet', u'coldsweet Documentation',
+     [author], 1)
+]
+
+
+# -- Options for Texinfo output ----------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+    (master_doc, 'coldsweet', u'coldsweet Documentation',
+     author, 'coldsweet', 'One line description of project.',
+     'Miscellaneous'),
+]
+
+
+# -- Options for Epub output -------------------------------------------------
+
+# Bibliographic Dublin Core info.
+epub_title = project
+
+# The unique identifier of the text. This can be a ISBN number
+# or the project homepage.
+#
+# epub_identifier = ''
+
+# A unique identification for the text.
+#
+# epub_uid = ''
+
+# A list of files that should not be packed into the epub file.
+epub_exclude_files = ['search.html']
+
+
+# -- Extension configuration -------------------------------------------------
diff --git a/docs/deploy.rst b/docs/deploy.rst
new file mode 100644
index 0000000..b2cf186
--- /dev/null
+++ b/docs/deploy.rst
@@ -0,0 +1,71 @@
+There are several ways to deploy Coldsweat on a "production" server.
+Here you can find the most common ones like FastCGI and less common like
+Passenger and Gunicorn+Nginx.
+
+Deploy using CGI and Apache (slow, not recommended)
+---------------------------------------------------
+
+Deploy using CGI and Apache's VirtualHost directive is pretty
+straightforward.
+
+::
+
+   <VirtualHost *:80>
+       DocumentRoot "/path/to/coldsweat/static"
+       ServerName yourdomain.tld
+
+       # Set up CGI handler
+       AddHandler cgi-script .cgi
+       Options +FollowSymLinks +ExecCGI
+
+       DirectoryIndex index.cgi
+       ScriptAlias /index.cgi /path/to/coldsweat/index.cgi/
+   </VirtualHost>
+
+First set site root to the Coldsweat ``static`` directory, so
+configuration settings and database files won't be exposed to the web.
+Then the ScriptAlias directive maps the index file to the ``/index.cgi``
+request.
+
+Finally make sure index file is executable by issuing a *chmod* command:
+
+::
+
+   chmod 755 index.cgi
+
+Deploy using FastCGI and Apache
+-------------------------------
+
+Deploy using FastCGI and Apache's VirtualHost directive is also
+straightforward and quite similar to the CGI deploy.
+
+::
+
+   <VirtualHost *:80>
+       DocumentRoot "/path/to/coldsweat/static"
+       ServerName yourdomain.tld
+
+       # Set up FastCGI handler
+       AddHandler fastcgi-script .fcgi
+       Options +FollowSymLinks +ExecCGI
+
+       DirectoryIndex dispatch.fcgi
+       ScriptAlias /dispatch.fcgi /path/to/coldsweat/dispatch.fcgi/
+   </VirtualHost>
+
+As usual the ``dispatch.fcgi`` file must have executable permissions.
+
+Deploy using Passenger
+----------------------
+
+See `Deploy on DreamHost`_.
+
+Deploy using Gunicorn and Nginx
+-------------------------------
+
+-  See `Using Coldsweat with Nginx`_
+-  See `Using Coldsweat with Gunicorn and Nginx`_
+
+.. _Deploy on DreamHost: Deploy on DreamHost
+.. _Using Coldsweat with Nginx: Using Coldsweat with Nginx
+.. _Using Coldsweat with Gunicorn and Nginx: Using Coldsweat with Gunicorn and Nginx
diff --git a/docs/fetcher-features.rst b/docs/fetcher-features.rst
new file mode 100644
index 0000000..2c28574
--- /dev/null
+++ b/docs/fetcher-features.rst
@@ -0,0 +1,29 @@
+These are some Coldsweat fetcher features worth noting.
+
+HTTP Conditional GET
+~~~~~~~~~~~~~~~~~~~~
+
+Ask for feed freshness before doing a regular GET of the data.
+
+Blacklisted sites
+~~~~~~~~~~~~~~~~~
+
+Each entry link or image is scanned and if their href or src attributes
+match one of the blacklisted sites element is simply removed from the
+saved entry. This way most ad tracking/social networks junk are removed
+before entry gets delivered to clients.
+
+Follow feed location changes
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Fetcher follows "301 Moved Permanently" HTTP status code and update feed
+information accordingly. The typical scenario is a feed previously self
+hosted then moved to Feedburner - or the other way around.
+
+Disabled feeds
+~~~~~~~~~~~~~~
+
+For each feed Coldsweat keeps has an error counter, so after a given
+maximum numbers of errors a feed gets disabled. This avoids waste
+bandwidth and time by polling dead feeds forever. Fetcher also
+understands "410 Gone" HTTP status code and disable feed immediately.
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..6e7ef29
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,23 @@
+.. coldsweet documentation master file, created by
+   sphinx-quickstart on Wed Aug 21 22:26:25 2019.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Welcome to coldsweet's documentation!
+=====================================
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   setup
+   using-with-nginx
+   fetcher-features
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/setup.rst b/docs/setup.rst
new file mode 100644
index 0000000..0ff5684
--- /dev/null
+++ b/docs/setup.rst
@@ -0,0 +1,122 @@
+First, download Coldsweat or clone the repository into your installation directory. 
+
+== Python version
+Starting version 0.9.5 Python 2.7.x is required. 
+
+Actually Coldsweat isn't compatible with Python 3.x. To see what's version of Python is installed in your system run Python with the -V option:
+{{{
+$ python -V
+Python 2.7.9
+}}}
+
+**Please note**: feeds using HTTPS protocol are best handled using a //recent// version of Python 2.7.x. I've tested them against 2.7.9 on OS X and they work well, but I've found issues on a deploy machine using 2.7.3.
+
+== Install dependencies
+
+Coldsweat uses various third-party packages:
+
+* Feedparser
+* Peewee 
+* Requests
+* WebOb
+* Tempita
+* [[Flup|https://pypi.python.org/pypi/flup]], for FastCGI support (optional)
+* [[Mysql-python|https://pypi.python.org/pypi/MySQL-python]], for MySQL suppport  (optional)
+* [[psycopg2|https://pypi.python.org/pypi/psycopg2]], for PostgreSQL suppport  (optional)
+
+**Note**: you can use [[PyMySQL|https://pypi.python.org/pypi/PyMySQL]] instead of MySQL-python if you have trouble installing the latter.
+
+If you don't have the Pip utility first install it with:
+{{{
+$ [sudo] easy_install pip
+}}}
+
+Then change to the directory where you installed Coldsweat and install all the mandatory dependencies:
+{{{
+$ cd /path/to/coldsweat
+$ [sudo] pip install -r requirements.txt
+}}}
+
+== Create the etc/config file
+Copy or rename the sample configuration file:
+{{{
+$ cp etc/config-sample etc/config 
+}}}
+
+You may want to take a look at {{{config}}} and tweak few options before proceed. 
+
+== Create the database
+Several Coldsweat features can be accessed via the sweat command-line utility. There are a number of available commands, like //setup//:  
+{{{
+$ python sweat.py setup
+}}}
+
+By default the script will create the {{{data/coldsweat.db}}} file using Sqlite as database engine. A default user named //coldsweat// will also be created and you will be asked for an e-mail and a password. 
+
+If you would like to use a different username run the sweat utility with the -u option:
+{{{
+$ python sweat.py setup -u johndoe
+Enter e-mail for user johndoe (needed for Fever sync, hit enter to leave blank): john.d@example.com
+Enter password for user johndoe: 
+Enter password (again): 
+Setup for user johndoe completed.
+}}}
+You can run again the //setup// command to create as many users you like. 
+
+If you forget your password run setup again with the -w (--password) option to reset it:
+
+{{{
+$ python sweat.py setup -w -u coldsweat
+Reset password for user coldsweat: 
+Enter password (again): 
+}}}
+
+== Import and fetch feeds
+The //import// command will load a given OPML file and assign it to the default user //coldsweat//:
+{{{
+$ python sweat.py import /path/to/subscriptions.xml
+}}}
+
+The subscriptions file has the same format produced by the Takeout procedure while exporting your Google Reader feeds. 
+
+Again, if you would like to assign imported feeds to a different user run the sweat utility with the -u option:
+{{{
+$ python sweat.py import -u johndoe /path/to/subscriptions.xml
+}}}
+
+For testing purposes there's a sample OPML file called {{{coldsweat/tests/markup/subscriptions.xml}}}. To import it run: 
+{{{
+$ python sweat.py import coldsweat/tests/markup/subscriptions.xml
+}}}
+
+To fetch feeds automatically after import add the {{{-f}}} option:
+{{{
+$ python sweat.py import -f coldsweat/tests/markup/subscriptions.xml
+}}}
+
+To start a feed fetch manually run: 
+{{{
+$ python sweat.py fetch
+}}}
+
+You should use Cron or similar utilities to schedule refreshes on a regular basis.
+
+== Starting a local server
+Coldsweat includes an HTTP server to try it out locally. Use the //serve// command to start the server on port 8080:
+{{{
+$ python sweat.py serve
+Serving on http://localhost:8080
+^CInterrupted by user
+}}}
+
+If you would like to use a different port number specify it with the {{{-p}}} option: 
+{{{
+$ python sweat.py serve -p 3333
+Serving on http://localhost:3333
+}}}
+
+== Deploy
+Once you have a working Coldsweat installation you are ready to [[deploy it|Deploy]].
+
+
+
diff --git a/docs/using-with-nginx.rst b/docs/using-with-nginx.rst
new file mode 100644
index 0000000..ab76955
--- /dev/null
+++ b/docs/using-with-nginx.rst
@@ -0,0 +1,41 @@
+If you want to run Coldsweat with Ngnix web server you need to bind it
+to a socket. This is a modified ``dispatch.fcgi`` file suitable to run
+Coldsweat using FastCGI.
+
+\``\` python
+
+#. !/usr/bin/env python """ Boostrap file for FastCGI environments """
+
+try: from flup.server.fcgi_fork import WSGIServer except ImportError,
+exc: print 'Error: unable to import Flup package.\nColdsweat needs Flup
+to run as a FastCGI process.' raise exc
+
+from coldsweat.app import setup_app
+
+if \__name_\_ == '__main__':
+WSGIServer(setup_app(),bindAddress='/tmp/coldsweat-fcgi.sock').run()
+\``\`
+
+Then, to have Coldsweat working behind NGinx at "/" (e.g.
+http://myhost.example.com/) you change the ``nginx.conf`` file entries
+like this:
+
+\``\` location / { root /path/to/coldsweat; include fastcgi_params;
+fastcgi_param PATH_INFO $fastcgi_script_name; fastcgi_param SCRIPT_NAME
+""; fastcgi_pass 127.0.0.1:7070; }
+
+location /static/ { root /path/to/coldsweat; } \``\`
+
+Then in ``etc/config`` file you set the ``static_url`` setting to:
+
+\``\` static_url: /static \``\`
+
+See also
+~~~~~~~~
+
+-  `Using Coldsweat with Gunicorn and Nginx`_
+-  `https://github.com/passiomatic/coldsweat/issues/21`_ for more
+   information.
+
+.. _Using Coldsweat with Gunicorn and Nginx: Using Coldsweat with Gunicorn and Nginx
+.. _`https://github.com/passiomatic/coldsweat/issues/21`: issue #21
diff --git a/etc/config-sample b/etc/config-sample
index f9fb6f6..00bff1b 100644
--- a/etc/config-sample
+++ b/etc/config-sample
@@ -9,10 +9,10 @@ connection_url: sqlite:///data/coldsweat.db
 [log]
 
 ; Values are: DEBUG (more verbose), INFO, WARNING, ERROR, CRITICAL (less verbose)
-;level: INFO
+level: debug
 
 ; Comment to avoid logging to a file altogether
-filename: coldsweat.log
+filename: stderr
 
 [fetcher]
 
@@ -47,4 +47,4 @@ filename: coldsweat.log
 ; with these host names will be scrubbed upon fetching. A partial 
 ; match is performed, e.g. the "feedsportal.com" line will match
 ; both ad.feedsportal.com and feedsportal.com.
-scrubber_blacklist: feeds.feedburner.com,feedsportal.com,stats.wordpress.com
\ No newline at end of file
+scrubber_blacklist: feeds.feedburner.com,feedsportal.com,stats.wordpress.com
diff --git a/index.cgi b/index.cgi
deleted file mode 100755
index fe85fad..0000000
--- a/index.cgi
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-'''
-Description: bootstrap file for CGI environments
-
-Copyright (c) 2013—2016 Andrea Peltrin
-License: MIT (see LICENSE for details)
-'''
-
-from wsgiref.handlers import CGIHandler
-from coldsweat.app import setup_app
-
-if __name__ == '__main__':
-    CGIHandler().run(setup_app())
-
-
diff --git a/makefile b/makefile
deleted file mode 100644
index 5d6e35b..0000000
--- a/makefile
+++ /dev/null
@@ -1,13 +0,0 @@
-CSS_FILES=./static/stylesheets/all.scss:./static/stylesheets/all.css
-
-all: update
-
-update:
-	sass -f -t compressed --update $(CSS_FILES)
-
-watch:
-	sass --watch $(CSS_FILES)
-
-clean:
-	rm -r ./.sass-cache
-	
diff --git a/requirements.txt b/requirements.txt
index 9902898..4b7df45 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,15 +1,22 @@
-Feedparser==5.2.1
-Peewee==2.7.3
-Requests==2.20.1
-WebOb==1.3.1
+certifi==2019.6.16
+chardet==3.0.4
+feedparser==5.2.1
+idna==2.7
+Paste==3.1.0
+peewee==3.10.0
+requests==2.20.1
+six==1.12.0
 Tempita==0.5.1
-
+urllib3==1.24.3
+WebOb==1.8.5
 # Run Coldsweat in FastCGI enviroments (optional)
 #Flup==1.0.3.dev-20110405
-
+-
 # Connect Coldsweat to a MySQL database (optional, pick PyMySQL if you have trouble installing Mysql-python)
 #Mysql-python
 #PyMySQL
 
 # Connect Coldsweat to a PostgreSQL database (optional)
 #psycopg2
+
+passlib
diff --git a/wsgi.py b/wsgi.py
index 622c692..167ac3d 100644
--- a/wsgi.py
+++ b/wsgi.py
@@ -7,4 +7,4 @@
 '''
 
 from coldsweat.app import setup_app
-app = setup_app()
\ No newline at end of file
+app = setup_app()


Test patch:
diff --git a/coldsweat/tests/__init__.py b/coldsweat/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/coldsweat/tests/fetcher.py b/coldsweat/tests/fetcher.py
deleted file mode 100644
index df7970c..0000000
--- a/coldsweat/tests/fetcher.py
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-'''
-Description: 
-
-Copyright (c) 2013—2016 Andrea Peltrin
-License: MIT (see LICENSE for details)
-'''
-from requests.exceptions import *
-
-from ..fetcher import fetch_url
-
-TEST_FEEDS = (
-    (None, 'http://www.aaa.bbb/'),                              # Does not exist
-    (200, 'http://www.scripting.com/rss.xml'),                  # First redirect, then 200
-    (404, 'http://example.com/wrong-rss.xml'),
-)
-
-
-def run_tests():    
-    for expected_status, url in TEST_FEEDS:
-        print 'Checking', url, '...'
-        try:
-            response = fetch_url(url, timeout=5)
-        except RequestException:
-            response = None
-        
-        assert (response == expected_status) or (response.status_code == expected_status)
-    
-
-if __name__ == '__main__':
-    run_tests()
-
diff --git a/coldsweat/tests/scrub.py b/coldsweat/tests/scrub.py
deleted file mode 100644
index 49aceb2..0000000
--- a/coldsweat/tests/scrub.py
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-'''
-Description: scrub tests
-
-Copyright (c) 2013—2016 Andrea Peltrin
-License: MIT (see LICENSE for details)
-'''
-
-import feedparser    
-
-from os import path
-from ..markup import scrub_html
-
-def run_tests():
-
-    # Figure out current dir
-    test_dir = path.dirname(path.abspath(__file__))
-
-    blacklist = "feedsportal.com feeds.feedburner.com".split()
-
-    test_files = [
-        ('markup/sample1.xml', blacklist[0]),
-        ('markup/sample2.xml', blacklist[1]),
-    ]
-
-    for filename, unwanted in test_files:           
-        soup = feedparser.parse(path.join(test_dir, filename))
-        for entry in soup.entries:    
-            data = scrub_html(entry.description, blacklist)
-            assert data.count(unwanted) == 0
-            print entry.title, '(OK)'
-        
-
-
-if __name__ == "__main__":
-    run_tests()
\ No newline at end of file
diff --git a/coldsweat/tests/strip.py b/coldsweat/tests/strip.py
deleted file mode 100644
index 54d5469..0000000
--- a/coldsweat/tests/strip.py
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-'''
-Description: HTML stripping tests
-
-Copyright (c) 2013—2016 Andrea Peltrin
-License: MIT (see LICENSE for details)
-'''
-
-from ..markup import strip_html
-
-def run_tests():
-    tests = [
-        ('a', 'a'),                                         # Identity
-        ('a <p class="c"><span>b</span></p> a', 'a b a'),        
-        (u'à <p class="c"><span>b</span></p> à', u'à b à'), # Unicode
-        ('a&amp;a&lt;a&gt;', 'a&a<a>'),                     # Test unescape of entity and char reference too
-        ('<span>a</span>', 'a'),
-        ('<span>a', 'a'),                                   # Unclosed elements
-        ('<p><span>a</p>', 'a'),    
-        ('<foo attr=1><bar />a</foo>', 'a'),                # Non HTML tags
-    ]
-    
-    for value, wanted in tests:
-        assert strip_html(value) == wanted
-
-if __name__ == "__main__":
-    run_tests()
\ No newline at end of file
diff --git a/tests-requirements.txt b/tests-requirements.txt
new file mode 100644
index 0000000..7694d22
--- /dev/null
+++ b/tests-requirements.txt
@@ -0,0 +1,2 @@
+pytest
+pytest-coverage
diff --git a/coldsweat/tests/discovery/html4-base.html b/tests/discovery/html4-base.html
similarity index 100%
rename from coldsweat/tests/discovery/html4-base.html
rename to tests/discovery/html4-base.html
diff --git a/coldsweat/tests/discovery/html4-no-base.html b/tests/discovery/html4-no-base.html
similarity index 100%
rename from coldsweat/tests/discovery/html4-no-base.html
rename to tests/discovery/html4-no-base.html
diff --git a/coldsweat/tests/discovery/html5-xhtml.html b/tests/discovery/html5-xhtml.html
similarity index 100%
rename from coldsweat/tests/discovery/html5-xhtml.html
rename to tests/discovery/html5-xhtml.html
diff --git a/coldsweat/tests/discovery/xhtml.html b/tests/discovery/xhtml.html
similarity index 100%
rename from coldsweat/tests/discovery/xhtml.html
rename to tests/discovery/xhtml.html
diff --git a/coldsweat/tests/markup/in.xml b/tests/markup/in.xml
similarity index 100%
rename from coldsweat/tests/markup/in.xml
rename to tests/markup/in.xml
diff --git a/coldsweat/tests/markup/out.xml b/tests/markup/out.xml
similarity index 90%
rename from coldsweat/tests/markup/out.xml
rename to tests/markup/out.xml
index 739a489..f604588 100644
--- a/coldsweat/tests/markup/out.xml
+++ b/tests/markup/out.xml
@@ -21,9 +21,9 @@
 <p>One of a kind, estimated price: $40–60,000. And gorgeous.</p>
 
 <p>(<a href="https://twitter.com/panzer/status/393864967037784064">Via Matthew Panzarino</a>.)</p>
-<img width="100" height="100" src="http://example.com/sample.png" alt="Sample">
+<img width="100" height="100" src="http://example.com/sample.png" alt="Sample" />
 <div>
-<a  title="Permanent link to ‘Product RED Mac Pro’"  href="http://daringfireball.net/linked/2013/10/25/red-mac-pro"> ★ </a>
+<a title="Permanent link to ‘Product RED Mac Pro’" href="http://daringfireball.net/linked/2013/10/25/red-mac-pro"> ★ </a>
 </div>
 
 	]]></content>
@@ -60,7 +60,7 @@ architecture around him. Wow!</p>
 </blockquote>
 
 <div>
-<a  title="Permanent link to ‘Theodolite Goes to the Great Wall of China’"  href="http://daringfireball.net/linked/2013/10/25/theodolite-china"> ★ </a>
+<a title="Permanent link to ‘Theodolite Goes to the Great Wall of China’" href="http://daringfireball.net/linked/2013/10/25/theodolite-china"> ★ </a>
 </div>
 
 	]]></content>
diff --git a/coldsweat/tests/markup/sample1.xml b/tests/markup/sample1.xml
similarity index 100%
rename from coldsweat/tests/markup/sample1.xml
rename to tests/markup/sample1.xml
diff --git a/coldsweat/tests/markup/sample2.xml b/tests/markup/sample2.xml
similarity index 100%
rename from coldsweat/tests/markup/sample2.xml
rename to tests/markup/sample2.xml
diff --git a/coldsweat/tests/markup/subscriptions.xml b/tests/markup/subscriptions.xml
similarity index 100%
rename from coldsweat/tests/markup/subscriptions.xml
rename to tests/markup/subscriptions.xml
diff --git a/tests/test_coldsweet.py b/tests/test_coldsweet.py
new file mode 100644
index 0000000..2ad7693
--- /dev/null
+++ b/tests/test_coldsweet.py
@@ -0,0 +1,6 @@
+
+import coldsweat
+
+
+def test_true():
+    assert True
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..93d8d53
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,4 @@
+from coldsweat import commands
+
+def test_somecommand():
+    assert True
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..8a3ad8f
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,2 @@
+
+from coldsweat import config
diff --git a/coldsweat/tests/discovery.py b/tests/test_discovery.py
similarity index 75%
rename from coldsweat/tests/discovery.py
rename to tests/test_discovery.py
index 6507334..455e9f4 100644
--- a/coldsweat/tests/discovery.py
+++ b/tests/test_discovery.py
@@ -8,32 +8,30 @@
 '''
 
 from os import path
-from ..markup import find_feed_links
+from coldsweat.markup import find_feed_links
+
 
 def find_feed_link(data, base_url):
     links = find_feed_links(data, base_url)
-    if links:        
-        return links[0]    
-    return None    
-    
-def run_tests():
+    if links:
+        return links[0]
+    return None
+
+
+def test_discovery():
 
     # Figure out current dir
     test_dir = path.dirname(path.abspath(__file__))
 
     test_files = [
         ('discovery/html5-xhtml.html', 'http://example.com/feed'),
-        ('discovery/xhtml.html', 'http://somedomain.com/articles.xml' ),
-        ('discovery/html4-base.html', 'http://somedomain.com/articles.xml' ),
-        ('discovery/html4-no-base.html', 'http://example.com/articles.xml' ),
+        ('discovery/xhtml.html', 'http://somedomain.com/articles.xml'),
+        ('discovery/html4-base.html', 'http://somedomain.com/articles.xml'),
+        ('discovery/html4-no-base.html', 'http://example.com/articles.xml'),
     ]
-           
+
     for filename, expected_url in test_files:
         with open(path.join(test_dir, filename)) as f:
             url, title = find_feed_link(f.read(), 'http://example.com')
             assert url == expected_url
-            print 'Found', url, title, '(OK)'
-        
-if __name__ == '__main__':
-    run_tests()
-
+            print('Found', url, title, '(OK)')
diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py
new file mode 100644
index 0000000..c723cfa
--- /dev/null
+++ b/tests/test_fetcher.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+'''
+Copyright (c) 2013—2016 Andrea Peltrin
+License: MIT (see LICENSE for details)
+'''
+import pytest
+from requests.exceptions import RequestException
+
+from coldsweat.fetcher import fetch_url
+
+TEST_FEEDS = (
+    (None, 'http://www.aaa.bbb/'),
+    (200, 'http://www.scripting.com/rss.xml'),
+    (404, 'http://example.com/wrong-rss.xml'),
+)
+
+
+@pytest.mark.parametrize("status,url", TEST_FEEDS)
+def test_fetcher(status, url):
+    print('Checking', url, '...')
+    try:
+        response = fetch_url(url, timeout=5)
+    except RequestException:
+        response = None
+    assert (response == status) or (response.status_code == status)
diff --git a/coldsweat/tests/fever.py b/tests/test_fever.py
similarity index 51%
rename from coldsweat/tests/fever.py
rename to tests/test_fever.py
index 2fd972d..d8acd97 100755
--- a/coldsweat/tests/fever.py
+++ b/tests/test_fever.py
@@ -6,14 +6,18 @@
 Copyright (c) 2013—2016 Andrea Peltrin
 License: MIT (see LICENSE for details)
 '''
-import sys, subprocess, optparse
+import optparse
+import subprocess
+
 from datetime import datetime
 
-from ..utilities import datetime_as_epoch
-from ..models import User
+from coldsweat.utilities import datetime_as_epoch
+from coldsweat.models import User
 
 TEST_USER_CREDENTIALS = 'test@example.com', 'coldsweat'
-ALL = 'groups feeds unread_item_ids saved_item_ids favicons items links unread_recently_read mark_item mark_feed mark_group mark_all'.split()
+ALL = ('groups feeds unread_item_ids saved_item_ids favicons items links '
+       'unread_recently_read mark_item mark_feed mark_group mark_all').split()
+
 
 def run_tests(endpoint, suites=ALL):
     """
@@ -23,7 +27,7 @@ def run_tests(endpoint, suites=ALL):
     epoch = datetime_as_epoch(datetime.utcnow())
 
     queries = []
-    
+
     if 'groups' in suites:
         queries.extend([
             (False, 'groups')
@@ -51,93 +55,100 @@ def run_tests(endpoint, suites=ALL):
 
     if 'items' in suites:
         queries.extend([
-            (False, 'items'),         
-            (False, 'items&with_ids=50,51,52'),         
+            (False, 'items'),
+            (False, 'items&with_ids=50,51,52'),
             (False, 'items&max_id=5'),
             (False, 'items&since_id=50')
         ])
 
     if 'links' in suites:
         queries.extend([
-            (False, 'links')                                        # Unsupported
-        ])          
+            (False, 'links')                                 # Unsupported
+        ])
 
     if 'mark_item' in suites:
         queries.extend([
-            (True, 'mark=item&as=read&id=1'), 
-            (True, 'mark=item&as=read&id=1'),                       # Dupe
-            (True, 'mark=item&as=read&id=0'),                       # Does not exist
-            (True, 'mark=item&as=unread&id=1'), 
-            (True, 'mark=item&as=saved&id=1'), 
-            (True, 'mark=item&as=saved&id=1'),                      # Dupe
-            (True, 'mark=item&as=saved&id=0'),                      # Does not exist
-            (True, 'mark=item&as=unsaved&id=1'), 
-        ]) 
-        
+            (True, 'mark=item&as=read&id=1'),
+            (True, 'mark=item&as=read&id=1'),                # Dupe
+            (True, 'mark=item&as=read&id=0'),                # Does not exist
+            (True, 'mark=item&as=unread&id=1'),
+            (True, 'mark=item&as=saved&id=1'),
+            (True, 'mark=item&as=saved&id=1'),               # Dupe
+            (True, 'mark=item&as=saved&id=0'),               # Does not exist
+            (True, 'mark=item&as=unsaved&id=1'),
+        ])
+
     if 'mark_feed' in suites:
         queries.extend([
-            (True, 'mark=feed&as=read&id=1&before=%d' % epoch), 
-            (True, 'mark=feed&as=read&id=1&before=abc'),            # Malformed
-            (True, 'mark=blah&as=read&id=1&before=%d' % epoch),     # Malformed (3)
-            (True, 'mark=feed&as=read&id=foo&before=abc'),          # Malformed (4)
-            (True, 'mark=feed&as_=read&id=1&before=%d' % epoch),    # Malformed (5)
-            (True, 'mark=feed&as=read&id=0&before=%d' % epoch),     # Does not exist
-        ]) 
+            (True, 'mark=feed&as=read&id=1&before=%d' % epoch),
+            (True, 'mark=feed&as=read&id=1&before=abc'),         # Malformed
+            (True, 'mark=blah&as=read&id=1&before=%d'
+             % epoch),  # Malformed (3)
+            (True, 'mark=feed&as=read&id=foo&before=abc'),     # Malformed (4)
+            (True, 'mark=feed&as_=read&id=1&before=%d'
+             % epoch),    # Malformed (5)
+            (True, 'mark=feed&as=read&id=0&before=%d'
+             % epoch),  # Does not exist
+        ])
 
     if 'mark_group' in suites:
         queries.extend([
-            (True, 'mark=group&as=read&id=1&before=%d' % epoch), 
-            (True, 'mark=group&as=read&id=1&before=abc'),           # Malformed 
-            (True, 'mark=group&as=read&id=-1&before=%d' % epoch),   # Unsupported
-        ]) 
+            (True, 'mark=group&as=read&id=1&before=%d' % epoch),
+            (True, 'mark=group&as=read&id=1&before=abc'),     # Malformed
+            (True, 'mark=group&as=read&id=-1&before=%d'
+             % epoch),   # Unsupported
+        ])
 
     if 'mark_all' in suites:
         queries.extend([
-            (True, 'mark=group&as=read&id=0&before=%d' % epoch),    # Mark all as read 
-        ])         
+            (True, 'mark=group&as=read&id=0&before=%d'
+             % epoch),  # Mark all as read
+        ])
 
     if 'unread_read' in suites:
         queries.extend([
             (True, 'unread_recently_read=1')
-        ])          
-                              
+        ])
+
     # Test auth failure
-    print ('\n= auth (failure)\n')
+    print('\n= auth (failure)\n')
 
     subprocess.call([
-        "curl", 
+        "curl",
         "-dapi_key=%s" % 'wrong-api-key',
         "%s?api&unread_item_ids" % endpoint
     ])
-    
-    api_key=User.make_api_key(*TEST_USER_CREDENTIALS)
 
-    # Test API commands            
+    api_key = User.make_api_key(*TEST_USER_CREDENTIALS)
+
+    # Test API commands
     for as_form, q in queries:
-        print ('\n= %s\n' % q)
+        print('\n= %s\n' % q)
 
         if as_form:
             subprocess.call([
-                "curl", 
+                "curl",
                 "-dapi_key=%s" % api_key,
                 "-d%s" % q,
                 "%s?api" % endpoint
             ])
         else:
             subprocess.call([
-                "curl", 
+                "curl",
                 "-dapi_key=%s" % api_key,
                 "%s?api&%s" % (endpoint, q),
             ])
-            
+
 
 parser = optparse.OptionParser(
     usage='%prog endpoint [-s suite]'
     )
 parser.add_option(
     '-s', '--suite',
-    dest='suite', 
-    help='the Fever API test suite to run. Available suites are: groups, feeds, unread, saved, favicons, items, links, unread_read, mark_item, mark_feed mark_group, mark_all')
+    dest='suite',
+    help=('the Fever API test suite to run. Available suites are: groups,'
+          'feeds,unread, saved, favicons, items, links, unread_read,'
+          'mark_item,mark_feed mark_group, mark_all'))
 
 
 if __name__ == '__main__':
@@ -145,11 +156,12 @@ def run_tests(endpoint, suites=ALL):
     options, args = parser.parse_args()
 
     if not args:
-        parser.error("no Fever API endpoint given, e.g.: http://localhost:8080/fever/")
+        parser.error("no Fever API endpoint given, e.g.:"
+                     "http://localhost:8080/fever/")
     elif len(args) > 1:
-        parser.error("extraneous argument found, use -s suite to run a specific test suite")
-        
+        parser.error("extraneous argument found, use -s suite to run a "
+                     "specific test suite")
+
     suite = options.suite if options.suite else ALL
-    
-    run_tests(args[0], suite)
 
+    run_tests(args[0], suite)
diff --git a/tests/test_frontend.py b/tests/test_frontend.py
new file mode 100644
index 0000000..207087f
--- /dev/null
+++ b/tests/test_frontend.py
@@ -0,0 +1,22 @@
+from coldsweat import frontend
+
+# TODO: test list unread entries
+
+# TODO: add test for bookmarking:
+# "POST /entries/7 HTTP/1.1" 200 0
+# {"Form data":{"mark":"","as":"saved"}}
+
+# TODO: add test for removing bookmark
+# "POST /entries/7 HTTP/1.1" 200 0
+# {"Form data":{"mark":"","as":"unsaved"}}
+
+# TODO add test for viewing bookmarks
+# 127.0.0.1 - - [25/Aug/2019 21:56:33] "GET /entries/?saved
+
+# TODO: list all entries
+
+# TODO: list all feeds
+
+# TODO: add subscription to feed
+
+# TODO: Test changing profile information
diff --git a/coldsweat/tests/processor.py b/tests/test_processor.py
similarity index 68%
rename from coldsweat/tests/processor.py
rename to tests/test_processor.py
index 561f8cf..af29f5d 100644
--- a/coldsweat/tests/processor.py
+++ b/tests/test_processor.py
@@ -1,4 +1,3 @@
-#!/usr/bin/env python
 # -*- coding: utf-8 -*-
 '''
 Description: base processor tests
@@ -7,12 +6,13 @@
 License: MIT (see LICENSE for details)
 '''
 
-import feedparser    
+import feedparser
 
 from os import path
-from ..markup import BaseProcessor
+from coldsweat.markup import BaseProcessor
 
-def run_tests():
+
+def test_processor():
 
     # Figure out current dir
     test_dir = path.dirname(path.abspath(__file__))
@@ -24,19 +24,14 @@ def run_tests():
     # Use xhtml_mode to match Feedpaser output
     p = BaseProcessor(xhtml_mode=True)
 
-    for file_in, file_out in test_files:           
+    for file_in, file_out in test_files:
         soup_in = feedparser.parse(path.join(test_dir, file_in))
         soup_out = feedparser.parse(path.join(test_dir, file_out))
-        for entry_in, entry_out in zip(soup_in.entries, soup_out.entries):    
+        for entry_in, entry_out in zip(soup_in.entries, soup_out.entries):
             p.reset()
             p.feed(entry_in.content[0].value)
             content_out = p.output()
-            print '>>>\n', entry_out.content[0].value
-            print '<<<\n', content_out
+            print('>>>\n', entry_out.content[0].value)
+            print('<<<\n', content_out)
             assert content_out == entry_out.content[0].value
-            print entry_in.title, '(OK)'
-        
-
-
-if __name__ == "__main__":
-    run_tests()
\ No newline at end of file
+            print(entry_in.title, '(OK)')
diff --git a/tests/test_scrub.py b/tests/test_scrub.py
new file mode 100644
index 0000000..4bbfdc0
--- /dev/null
+++ b/tests/test_scrub.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+'''
+Description: scrub tests
+
+Copyright (c) 2013—2016 Andrea Peltrin
+License: MIT (see LICENSE for details)
+'''
+
+import feedparser
+import pytest
+
+from os import path
+
+from coldsweat.markup import scrub_html
+
+blacklist = "feedsportal.com feeds.feedburner.com".split()
+
+
+@pytest.mark.parametrize("filename, unwanted", [
+ ('markup/sample1.xml', blacklist[0]),
+ ('markup/sample1.xml', blacklist[1])
+    ])
+def test_scrub(filename, unwanted):
+
+    # Figure out current dir
+    test_dir = path.dirname(path.abspath(__file__))
+    soup = feedparser.parse(path.join(test_dir, filename))
+    for entry in soup.entries:
+        data = scrub_html(entry.description, blacklist)
+        assert data.count(unwanted) == 0
+        print(entry.title, '(OK)')
diff --git a/tests/test_strip.py b/tests/test_strip.py
new file mode 100644
index 0000000..481ada4
--- /dev/null
+++ b/tests/test_strip.py
@@ -0,0 +1,27 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+'''
+Description: HTML stripping tests
+
+Copyright (c) 2013—2016 Andrea Peltrin
+License: MIT (see LICENSE for details)
+'''
+import pytest
+
+from coldsweat.markup import strip_html
+
+
+@pytest.mark.parametrize("value, wanted", [
+        ('a', 'a'),  # Identity
+        ('a <p class="c"><span>b</span></p> a', 'a b a'),
+        (u'à <p class="c"><span>b</span></p> à', u'à b à'),  # Unicode
+        ('a&amp;a&lt;a&gt;',
+            'a&a<a>'),  # Test unescape of entity and char reference too
+        ('<span>a</span>', 'a'),
+        ('<span>a', 'a'),  # Unclosed elements
+        ('<p><span>a</p>', 'a'),
+        ('<foo attr=1><bar />a</foo>', 'a'),  # Non HTML tags
+    ]
+    )
+def test_stripping_html(value, wanted):
+    assert strip_html(value) == wanted