connection pooling and new cache implementations

* add a wrapper around `sqlite3` to enable connection pooling across
  multiple threads.

  Most tests now use a in-memory database which speeds things (slightly)
  up. The database wrapper is now uncoupled from the actual database
  connection.

* split cache framework from core.Mixin into a separate package
  `isso.cache`. The dependency on `werkzeug.contrib` has been removed to
  ease a possible transition to a different web framework later.

  The default cache uses SQLite3 now (unless Isso is run from uWSGI).
  While it may sound like a Bad Idea (tm), it's much more efficient than
  per-process python datastructures. The SQLite3 cache is SMP-capable
  and fast for read-heavy sites.

  SQLite3 may fail due to a corrupt database for concurrent read access
  from multiple processes. The database is actually not corrupted, but
  the connection is stale. As a workaround, limit process number to your
  CPU count or wait until a "real" backend such as PostgreSQL is
  available.
This commit is contained in:
Martin Zimmermann 2014-06-27 11:24:02 +02:00
parent bbd9e1b523
commit 88689c789a
17 changed files with 470 additions and 150 deletions

View File

@ -25,7 +25,7 @@
# #
# Isso a lightweight Disqus alternative # Isso a lightweight Disqus alternative
from __future__ import print_function from __future__ import print_function, unicode_literals
import pkg_resources import pkg_resources
dist = pkg_resources.get_distribution("isso") dist = pkg_resources.get_distribution("isso")
@ -42,7 +42,6 @@ if sys.argv[0].startswith("isso"):
import os import os
import errno import errno
import logging import logging
import tempfile
from os.path import dirname, join from os.path import dirname, join
from argparse import ArgumentParser from argparse import ArgumentParser
@ -62,7 +61,12 @@ from werkzeug.contrib.profiler import ProfilerMiddleware
local = Local() local = Local()
local_manager = LocalManager([local]) local_manager = LocalManager([local])
from isso import config, db, migrate, wsgi, ext, views try:
import uwsgi
except ImportError:
uwsgi = None
from isso import cache, config, db, migrate, wsgi, ext, views
from isso.core import ThreadedMixin, ProcessMixin, uWSGIMixin from isso.core import ThreadedMixin, ProcessMixin, uWSGIMixin
from isso.wsgi import origin, urlsplit from isso.wsgi import origin, urlsplit
from isso.utils import http, JSONRequest, html, hash from isso.utils import http, JSONRequest, html, hash
@ -80,10 +84,18 @@ logger = logging.getLogger("isso")
class Isso(object): class Isso(object):
def __init__(self, conf): def __init__(self, conf, cacheobj=None, connection=None,
multiprocessing=False):
if cacheobj is None:
cacheobj = cache.Cache(threshold=1024)
if connection is None:
connection = db.SQLite3(":memory:")
self.conf = conf self.conf = conf
self.db = db.SQLite3(conf.get('general', 'dbpath'), conf) self.cache = cacheobj
self.connection = connection
self.db = db.Adapter(connection, conf)
self.signer = URLSafeTimedSerializer(self.db.preferences.get("session-key")) self.signer = URLSafeTimedSerializer(self.db.preferences.get("session-key"))
self.markup = html.Markup( self.markup = html.Markup(
conf.getlist("markup", "options"), conf.getlist("markup", "options"),
@ -92,6 +104,7 @@ class Isso(object):
self.hasher = hash.new( self.hasher = hash.new(
conf.get("hash", "algorithm"), conf.get("hash", "algorithm"),
conf.get("hash", "salt")) conf.get("hash", "salt"))
self.shared = True if multiprocessing else False
super(Isso, self).__init__(conf) super(Isso, self).__init__(conf)
@ -142,6 +155,10 @@ class Isso(object):
return InternalServerError() return InternalServerError()
else: else:
return response return response
finally:
# FIXME: always close connection but rather fix tests
if self.shared:
self.connection.close()
def wsgi_app(self, environ, start_response): def wsgi_app(self, environ, start_response):
response = self.dispatch(JSONRequest(environ)) response = self.dispatch(JSONRequest(environ))
@ -151,22 +168,29 @@ class Isso(object):
return self.wsgi_app(environ, start_response) return self.wsgi_app(environ, start_response)
def make_app(conf=None, threading=True, multiprocessing=False, uwsgi=False): def make_app(conf, multiprocessing=True):
if not any((threading, multiprocessing, uwsgi)): connection = db.SQLite3(conf.get("general", "dbpath"))
raise RuntimeError("either set threading, multiprocessing or uwsgi") cacheobj = cache.SQLite3Cache(connection, threshold=2048)
if threading: if multiprocessing:
if uwsgi is not None:
class App(Isso, uWSGIMixin):
pass
cacheobj = cache.uWSGICache(timeout=3600)
else:
class App(Isso, ProcessMixin):
pass
else:
class App(Isso, ThreadedMixin): class App(Isso, ThreadedMixin):
pass pass
elif multiprocessing:
class App(Isso, ProcessMixin):
pass
else:
class App(Isso, uWSGIMixin):
pass
isso = App(conf) isso = App(
conf,
cacheobj=cacheobj,
connection=connection,
multiprocessing=multiprocessing)
# check HTTP server connection # check HTTP server connection
for host in conf.getiter("general", "host"): for host in conf.getiter("general", "host"):
@ -226,12 +250,11 @@ def main():
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
if args.dryrun: if args.dryrun:
xxx = tempfile.NamedTemporaryFile() dbpath = ":memory:"
dbpath = xxx.name
else: else:
dbpath = conf.get("general", "dbpath") dbpath = conf.get("general", "dbpath")
mydb = db.SQLite3(dbpath, conf) mydb = db.Adapter(db.SQLite3(dbpath), conf)
migrate.dispatch(args.type, mydb, args.dump) migrate.dispatch(args.type, mydb, args.dump)
sys.exit(0) sys.exit(0)
@ -240,13 +263,15 @@ def main():
logger.error("No website(s) configured, Isso won't work.") logger.error("No website(s) configured, Isso won't work.")
sys.exit(1) sys.exit(1)
app = make_app(conf, multiprocessing=False)
if conf.get("server", "listen").startswith("http://"): if conf.get("server", "listen").startswith("http://"):
host, port, _ = urlsplit(conf.get("server", "listen")) host, port, _ = urlsplit(conf.get("server", "listen"))
try: try:
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
WSGIServer((host, port), make_app(conf)).serve_forever() WSGIServer((host, port), app).serve_forever()
except ImportError: except ImportError:
run_simple(host, port, make_app(conf), threaded=True, run_simple(host, port, app, threaded=True,
use_reloader=conf.getboolean('server', 'reload')) use_reloader=conf.getboolean('server', 'reload'))
else: else:
sock = conf.get("server", "listen").partition("unix://")[2] sock = conf.get("server", "listen").partition("unix://")[2]
@ -255,4 +280,4 @@ def main():
except OSError as ex: except OSError as ex:
if ex.errno != errno.ENOENT: if ex.errno != errno.ENOENT:
raise raise
wsgi.SocketHTTPServer(sock, make_app(conf)).serve_forever() wsgi.SocketHTTPServer(sock, app).serve_forever()

105
isso/cache/__init__.py vendored Normal file
View File

@ -0,0 +1,105 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import abc
import json
from isso.utils import types
from isso.compat import string_types
def pickle(value):
return json.dumps(value).encode("utf-8")
def unpickle(value):
return json.loads(value.decode("utf-8"))
class Base(object):
"""Base class for all cache objects.
Arbitrary values are set by namespace and key. Namespace and key must be
strings, the underlying cache implementation may use :func:`pickle` and
:func:`unpickle:` to safely un-/serialize Python primitives.
:param threshold: maximum size of the cache
:param timeout: key expiration
"""
__metaclass__ = abc.ABCMeta
# enable serialization of Python primitives
serialize = False
def __init__(self, threshold, timeout):
self.threshold = threshold
self.timeout = timeout
def get(self, ns, key, default=None):
types.require(ns, string_types)
types.require(key, string_types)
try:
value = self._get(ns.encode("utf-8"), key.encode("utf-8"))
except KeyError:
return default
else:
if self.serialize:
value = unpickle(value)
return value
@abc.abstractmethod
def _get(self, ns, key):
return
def set(self, ns, key, value):
types.require(ns, string_types)
types.require(key, string_types)
if self.serialize:
value = pickle(value)
return self._set(ns.encode("utf-8"), key.encode("utf-8"), value)
@abc.abstractmethod
def _set(self, ns, key, value):
return
def delete(self, ns, key):
types.require(ns, string_types)
types.require(key, string_types)
return self._delete(ns.encode("utf-8"), key.encode("utf-8"))
@abc.abstractmethod
def _delete(self, ns, key):
return
class Cache(Base):
"""Implements a simple in-memory cache; once the threshold is reached, all
cached elements are discarded (the timeout parameter is ignored).
"""
def __init__(self, threshold=512, timeout=-1):
super(Cache, self).__init__(threshold, timeout)
self.cache = {}
def _get(self, ns, key):
return self.cache[ns + b'-' + key]
def _set(self, ns, key, value):
if len(self.cache) > self.threshold - 1:
self.cache.clear()
self.cache[ns + b'-' + key] = value
def _delete(self, ns, key):
self.cache.pop(ns + b'-' + key, None)
from .uwsgi import uWSGICache
from .sqlite import SQLite3Cache
__all__ = ["Cache", "SQLite3Cache", "uWSGICache"]

58
isso/cache/sqlite.py vendored Normal file
View File

@ -0,0 +1,58 @@
# -*- encoding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
import time
from . import Base
from isso.db import SQLite3
class SQLite3Cache(Base):
"""Implements a shared cache using SQLite3. Works across multiple processes
and threads, concurrent writes are not supported.
JSON is used to serialize python primitives in a safe way.
"""
serialize = True
def __init__(self, connection, threshold=1024, timeout=-1):
super(SQLite3Cache, self).__init__(threshold, timeout)
self.connection = connection
self.connection.execute(
'CREATE TABLE IF NOT EXISTS cache ('
' key TEXT PRIMARY KEY,'
' value BLOB,'
' time FLOAT)')
# drop trigger, in case threshold has changed
self.connection.execute('DROP TRIGGER IF EXISTS sweeper')
self.connection.execute([
'CREATE TRIGGER sweeper AFTER INSERT ON cache',
'BEGIN',
' DELETE FROM cache WHERE key NOT IN ('
' SELECT key FROM cache',
' ORDER BY time DESC LIMIT {0}'.format(threshold),
' );',
'END'])
def _get(self, ns, key, default=None):
rv = self.connection.execute(
'SELECT value FROM cache WHERE key = ?',
(ns + b'-' + key, )).fetchone()
if rv is None:
raise KeyError
return rv[0]
def _set(self, ns, key, value):
with self.connection.transaction as con:
con.execute(
'INSERT OR REPLACE INTO cache (key, value, time) VALUES (?, ?, ?)',
(ns + b'-' + key, value, time.time()))
def _delete(self, ns, key):
with self.connection.transaction as con:
con.execute('DELETE FROM cache WHERE key = ?', (ns + b'-' + key, ))

34
isso/cache/uwsgi.py vendored Normal file
View File

@ -0,0 +1,34 @@
# -*- encoding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
try:
import uwsgi
except ImportError:
uwsgi = None
from . import Base
class uWSGICache(Base):
"""Utilize uWSGI caching framework, in-memory and SMP-safe.
"""
serialize = True
def __init__(self, threshold=-1, timeout=3600):
if uwsgi is None:
raise RuntimeError("uWSGI not available")
super(uWSGICache, self).__init__(threshold, timeout)
def _get(self, ns, key):
if not uwsgi.cache_exists(key, ns):
raise KeyError
return uwsgi.cache_get(key, ns)
def _delete(self, ns, key):
uwsgi.cache_del(key, ns)
def _set(self, ns, key, value):
uwsgi.cache_set(key, value, self.timeout, ns)

View File

@ -19,34 +19,13 @@ if PY2K:
else: else:
import _thread as thread import _thread as thread
from werkzeug.contrib.cache import NullCache, SimpleCache
logger = logging.getLogger("isso") logger = logging.getLogger("isso")
class Cache:
"""Wrapper around werkzeug's cache class, to make it compatible to
uWSGI's cache framework.
"""
def __init__(self, cache):
self.cache = cache
def get(self, cache, key):
return self.cache.get(key)
def set(self, cache, key, value):
return self.cache.set(key, value)
def delete(self, cache, key):
return self.cache.delete(key)
class Mixin(object): class Mixin(object):
def __init__(self, conf): def __init__(self, conf):
self.lock = threading.Lock() self.lock = threading.Lock()
self.cache = Cache(NullCache())
def notify(self, subject, body, retries=5): def notify(self, subject, body, retries=5):
pass pass
@ -72,8 +51,6 @@ class ThreadedMixin(Mixin):
if conf.getboolean("moderation", "enabled"): if conf.getboolean("moderation", "enabled"):
self.purge(conf.getint("moderation", "purge-after")) self.purge(conf.getint("moderation", "purge-after"))
self.cache = Cache(SimpleCache(threshold=1024, default_timeout=3600))
@threaded @threaded
def purge(self, delta): def purge(self, delta):
while True: while True:
@ -90,28 +67,6 @@ class ProcessMixin(ThreadedMixin):
self.lock = multiprocessing.Lock() self.lock = multiprocessing.Lock()
class uWSGICache(object):
"""Uses uWSGI Caching Framework. INI configuration:
.. code-block:: ini
cache2 = name=hash,items=1024,blocksize=32
"""
@classmethod
def get(self, cache, key):
return uwsgi.cache_get(key, cache)
@classmethod
def set(self, cache, key, value):
uwsgi.cache_set(key, value, 3600, cache)
@classmethod
def delete(self, cache, key):
uwsgi.cache_del(key, cache)
class uWSGIMixin(Mixin): class uWSGIMixin(Mixin):
def __init__(self, conf): def __init__(self, conf):
@ -119,7 +74,6 @@ class uWSGIMixin(Mixin):
super(uWSGIMixin, self).__init__(conf) super(uWSGIMixin, self).__init__(conf)
self.lock = multiprocessing.Lock() self.lock = multiprocessing.Lock()
self.cache = uWSGICache
timedelta = conf.getint("moderation", "purge-after") timedelta = conf.getint("moderation", "purge-after")
purge = lambda signum: self.db.comments.purge(timedelta) purge = lambda signum: self.db.comments.purge(timedelta)

View File

@ -1,8 +1,12 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
import sqlite3 from __future__ import unicode_literals
import logging import logging
import sqlite3
import operator import operator
import threading
import os.path import os.path
from collections import defaultdict from collections import defaultdict
@ -15,7 +19,75 @@ from isso.db.spam import Guard
from isso.db.preferences import Preferences from isso.db.preferences import Preferences
class SQLite3: class Transaction(object):
"""A context manager to lock the database across processes and automatic
rollback on failure. On success, reset the isolation level back to normal.
SQLite3's DEFERRED (default) transaction mode causes database corruption
for concurrent writes to the database from multiple processes. IMMEDIATE
ensures a global write lock, but reading is still possible.
"""
def __init__(self, con):
self.con = con
def __enter__(self):
self._orig = self.con.isolation_level
self.con.isolation_level = "IMMEDIATE"
self.con.execute("BEGIN IMMEDIATE")
return self.con
def __exit__(self, exc_type, exc_val, exc_tb):
try:
if exc_type:
self.con.rollback()
else:
self.con.commit()
finally:
self.con.isolation_level = self._orig
class SQLite3(object):
"""SQLite3 connection pool across multiple threads. Implementation idea
from `Peewee <https://github.com/coleifer/peewee>`_.
"""
def __init__(self, db):
self.db = os.path.expanduser(db)
self.lock = threading.Lock()
self.local = threading.local()
def connect(self):
with self.lock:
self.local.conn = sqlite3.connect(self.db, isolation_level=None)
def close(self):
with self.lock:
self.local.conn.close()
self.local.conn = None
def execute(self, sql, args=()):
if isinstance(sql, (list, tuple)):
sql = ' '.join(sql)
return self.connection.execute(sql, args)
@property
def connection(self):
if not hasattr(self.local, 'conn') or self.local.conn is None:
self.connect()
return self.local.conn
@property
def transaction(self):
return Transaction(self.connection)
@property
def total_changes(self):
return self.connection.total_changes
class Adapter(object):
"""DB-dependend wrapper around SQLite3. """DB-dependend wrapper around SQLite3.
Runs migration if `user_version` is older than `MAX_VERSION` and register Runs migration if `user_version` is older than `MAX_VERSION` and register
@ -24,9 +96,8 @@ class SQLite3:
MAX_VERSION = 3 MAX_VERSION = 3
def __init__(self, path, conf): def __init__(self, conn, conf):
self.connection = conn
self.path = os.path.expanduser(path)
self.conf = conf self.conf = conf
rv = self.execute([ rv = self.execute([
@ -40,9 +111,9 @@ class SQLite3:
self.guard = Guard(self) self.guard = Guard(self)
if rv is None: if rv is None:
self.execute("PRAGMA user_version = %i" % SQLite3.MAX_VERSION) self.execute("PRAGMA user_version = %i" % Adapter.MAX_VERSION)
else: else:
self.migrate(to=SQLite3.MAX_VERSION) self.migrate(to=Adapter.MAX_VERSION)
self.execute([ self.execute([
'CREATE TRIGGER IF NOT EXISTS remove_stale_threads', 'CREATE TRIGGER IF NOT EXISTS remove_stale_threads',
@ -51,14 +122,6 @@ class SQLite3:
' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);', ' DELETE FROM threads WHERE id NOT IN (SELECT tid FROM comments);',
'END']) 'END'])
def execute(self, sql, args=()):
if isinstance(sql, (list, tuple)):
sql = ' '.join(sql)
with sqlite3.connect(self.path) as con:
return con.execute(sql, args)
@property @property
def version(self): def version(self):
return self.execute("PRAGMA user_version").fetchone()[0] return self.execute("PRAGMA user_version").fetchone()[0]
@ -77,7 +140,7 @@ class SQLite3:
from isso.utils import Bloomfilter from isso.utils import Bloomfilter
bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array) bf = buffer(Bloomfilter(iterable=["127.0.0.0"]).array)
with sqlite3.connect(self.path) as con: with self.connection.transaction as con:
con.execute('UPDATE comments SET voters=?', (bf, )) con.execute('UPDATE comments SET voters=?', (bf, ))
con.execute('PRAGMA user_version = 1') con.execute('PRAGMA user_version = 1')
logger.info("%i rows changed", con.total_changes) logger.info("%i rows changed", con.total_changes)
@ -85,7 +148,7 @@ class SQLite3:
# move [general] session-key to database # move [general] session-key to database
if self.version == 1: if self.version == 1:
with sqlite3.connect(self.path) as con: with self.connection.transaction as con:
if self.conf.has_option("general", "session-key"): if self.conf.has_option("general", "session-key"):
con.execute('UPDATE preferences SET value=? WHERE key=?', ( con.execute('UPDATE preferences SET value=? WHERE key=?', (
self.conf.get("general", "session-key"), "session-key")) self.conf.get("general", "session-key"), "session-key"))
@ -98,7 +161,7 @@ class SQLite3:
first = lambda rv: list(map(operator.itemgetter(0), rv)) first = lambda rv: list(map(operator.itemgetter(0), rv))
with sqlite3.connect(self.path) as con: with self.connection.transaction as con:
top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall()) top = first(con.execute("SELECT id FROM comments WHERE parent IS NULL").fetchall())
flattened = defaultdict(set) flattened = defaultdict(set)
@ -117,3 +180,6 @@ class SQLite3:
con.execute('PRAGMA user_version = 3') con.execute('PRAGMA user_version = 3')
logger.info("%i rows changed", con.total_changes) logger.info("%i rows changed", con.total_changes)
def execute(self, sql, args=()):
return self.connection.execute(sql, args)

View File

@ -1,5 +1,7 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import os import os
import binascii import binascii

View File

@ -8,5 +8,4 @@ from isso import dist, config
application = make_app( application = make_app(
config.load( config.load(
os.path.join(dist.location, "isso", "defaults.ini"), os.path.join(dist.location, "isso", "defaults.ini"),
os.environ.get('ISSO_SETTINGS')), os.environ.get('ISSO_SETTINGS')))
multiprocessing=True)

62
isso/tests/test_cache.py Normal file
View File

@ -0,0 +1,62 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
try:
import unittest2 as unittest
except ImportError:
import unittest
from isso.compat import text_type as str
from isso.db import SQLite3
from isso.cache import Cache, SQLite3Cache
ns = "test"
class TestCache(unittest.TestCase):
def setUp(self):
self.cache = Cache(threshold=8)
def test_cache(self):
cache = self.cache
cache.delete(ns, "foo")
self.assertIsNone(cache.get(ns, "foo"))
cache.set(ns, "foo", "bar")
self.assertEqual(cache.get(ns, "foo"), "bar")
cache.delete(ns, "foo")
self.assertIsNone(cache.get(ns, "foo"))
def test_full(self):
cache = self.cache
cache.set(ns, "foo", "bar")
for i in range(7):
cache.set(ns, str(i), "Spam!")
for i in range(7):
self.assertEqual(cache.get(ns, str(i)), "Spam!")
self.assertIsNotNone(cache.get(ns, "foo"))
cache.set(ns, "bar", "baz")
self.assertIsNone(cache.get(ns, "foo"))
def test_primitives(self):
cache = self.cache
for val in (None, True, [1, 2, 3], {"bar": "baz"}):
cache.set(ns, "val", val)
self.assertEqual(cache.get(ns, "val"), val, val.__class__.__name__)
class TestSQLite3Cache(TestCache):
def setUp(self):
self.cache = SQLite3Cache(SQLite3(":memory:"), threshold=8)

View File

@ -4,7 +4,6 @@ from __future__ import unicode_literals
import os import os
import json import json
import tempfile
try: try:
import unittest2 as unittest import unittest2 as unittest
@ -31,9 +30,7 @@ http.curl = curl
class TestComments(unittest.TestCase): class TestComments(unittest.TestCase):
def setUp(self): def setUp(self):
fd, self.path = tempfile.mkstemp()
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini")) conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")
@ -49,9 +46,6 @@ class TestComments(unittest.TestCase):
self.post = self.client.post self.post = self.client.post
self.delete = self.client.delete self.delete = self.client.delete
def tearDown(self):
os.unlink(self.path)
def testGet(self): def testGet(self):
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'})) self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
@ -379,9 +373,7 @@ class TestComments(unittest.TestCase):
class TestModeratedComments(unittest.TestCase): class TestModeratedComments(unittest.TestCase):
def setUp(self): def setUp(self):
fd, self.path = tempfile.mkstemp()
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini")) conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
conf.set("general", "dbpath", self.path)
conf.set("moderation", "enabled", "true") conf.set("moderation", "enabled", "true")
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")
@ -393,9 +385,6 @@ class TestModeratedComments(unittest.TestCase):
self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1") self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1")
self.client = JSONClient(self.app, Response) self.client = JSONClient(self.app, Response)
def tearDown(self):
os.unlink(self.path)
def testAddComment(self): def testAddComment(self):
rv = self.client.post('/new?uri=test', data=json.dumps({"text": "..."})) rv = self.client.post('/new?uri=test', data=json.dumps({"text": "..."}))
@ -411,9 +400,7 @@ class TestModeratedComments(unittest.TestCase):
class TestPurgeComments(unittest.TestCase): class TestPurgeComments(unittest.TestCase):
def setUp(self): def setUp(self):
fd, self.path = tempfile.mkstemp()
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini")) conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
conf.set("general", "dbpath", self.path)
conf.set("moderation", "enabled", "true") conf.set("moderation", "enabled", "true")
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")

View File

@ -1,28 +1,51 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
from __future__ import unicode_literals
try: try:
import unittest2 as unittest import unittest2 as unittest
except ImportError: except ImportError:
import unittest import unittest
import os
import sqlite3
import tempfile
from isso import config from isso import config
from isso.db import SQLite3 from isso.db import SQLite3, Adapter
from isso.compat import iteritems from isso.compat import iteritems
class TestSQLite3(unittest.TestCase):
def test_connection(self):
con = SQLite3(":memory:")
con.connect()
self.assertTrue(hasattr(con.local, "conn"))
con.close()
self.assertIsNone(con.local.conn)
def test_autoconnect(self):
con = SQLite3(":memory:")
con.execute("")
self.assertTrue(hasattr(con.local, "conn"))
def test_rollback(self):
con = SQLite3(":memory:")
con.execute("CREATE TABLE foo (bar INTEGER)")
con.execute("INSERT INTO foo (bar) VALUES (42)")
try:
with con.transaction as con:
con.execute("INSERT INTO foo (bar) VALUES (23)")
raise ValueError("some error")
except ValueError:
pass
self.assertEqual(len(con.execute("SELECT bar FROM foo").fetchall()), 1)
class TestDBMigration(unittest.TestCase): class TestDBMigration(unittest.TestCase):
def setUp(self):
fd, self.path = tempfile.mkstemp()
def tearDown(self):
os.unlink(self.path)
def test_defaults(self): def test_defaults(self):
conf = config.new({ conf = config.new({
@ -31,9 +54,9 @@ class TestDBMigration(unittest.TestCase):
"max-age": "1h" "max-age": "1h"
} }
}) })
db = SQLite3(self.path, conf) db = Adapter(SQLite3(":memory:"), conf)
self.assertEqual(db.version, SQLite3.MAX_VERSION) self.assertEqual(db.version, Adapter.MAX_VERSION)
self.assertTrue(db.preferences.get("session-key", "").isalnum()) self.assertTrue(db.preferences.get("session-key", "").isalnum())
def test_session_key_migration(self): def test_session_key_migration(self):
@ -46,21 +69,23 @@ class TestDBMigration(unittest.TestCase):
}) })
conf.set("general", "session-key", "supersecretkey") conf.set("general", "session-key", "supersecretkey")
with sqlite3.connect(self.path) as con: connection = SQLite3(":memory:")
with connection.transaction as con:
con.execute("PRAGMA user_version = 1") con.execute("PRAGMA user_version = 1")
con.execute("CREATE TABLE threads (id INTEGER PRIMARY KEY)") con.execute("CREATE TABLE threads (id INTEGER PRIMARY KEY)")
db = SQLite3(self.path, conf) db = Adapter(connection, conf)
self.assertEqual(db.version, SQLite3.MAX_VERSION) self.assertEqual(db.version, Adapter.MAX_VERSION)
self.assertEqual(db.preferences.get("session-key"), self.assertEqual(db.preferences.get("session-key"),
conf.get("general", "session-key")) conf.get("general", "session-key"))
# try again, now with the session-key removed from our conf # try again, now with the session-key removed from our conf
conf.remove_option("general", "session-key") conf.remove_option("general", "session-key")
db = SQLite3(self.path, conf) db = Adapter(connection, conf)
self.assertEqual(db.version, SQLite3.MAX_VERSION) self.assertEqual(db.version, Adapter.MAX_VERSION)
self.assertEqual(db.preferences.get("session-key"), self.assertEqual(db.preferences.get("session-key"),
"supersecretkey") "supersecretkey")
@ -76,7 +101,9 @@ class TestDBMigration(unittest.TestCase):
6: None 6: None
} }
with sqlite3.connect(self.path) as con: connection = SQLite3(":memory:")
with connection.transaction as con:
con.execute("PRAGMA user_version = 2") con.execute("PRAGMA user_version = 2")
con.execute("CREATE TABLE threads (" con.execute("CREATE TABLE threads ("
" id INTEGER PRIMARY KEY," " id INTEGER PRIMARY KEY,"
@ -106,7 +133,7 @@ class TestDBMigration(unittest.TestCase):
"max-age": "1h" "max-age": "1h"
} }
}) })
SQLite3(self.path, conf) Adapter(connection, conf)
flattened = [ flattened = [
(1, None), (1, None),
@ -118,6 +145,6 @@ class TestDBMigration(unittest.TestCase):
(7, 2) (7, 2)
] ]
with sqlite3.connect(self.path) as con: with connection.transaction as con:
rv = con.execute("SELECT id, parent FROM comments ORDER BY created").fetchall() rv = con.execute("SELECT id, parent FROM comments ORDER BY created").fetchall()
self.assertEqual(flattened, rv) self.assertEqual(flattened, rv)

View File

@ -15,7 +15,7 @@ from werkzeug import __version__
from werkzeug.test import Client from werkzeug.test import Client
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from isso import Isso, config, core, dist from isso import Isso, config, core, db, dist
from isso.utils import http from isso.utils import http
from fixtures import curl, FakeIP from fixtures import curl, FakeIP
@ -33,12 +33,11 @@ class TestGuard(unittest.TestCase):
data = json.dumps({"text": "Lorem ipsum."}) data = json.dumps({"text": "Lorem ipsum."})
def setUp(self): def setUp(self):
self.path = tempfile.NamedTemporaryFile().name self.connection = db.SQLite3(":memory:")
def makeClient(self, ip, ratelimit=2, direct_reply=3, self_reply=False): def makeClient(self, ip, ratelimit=2, direct_reply=3, self_reply=False):
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini")) conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
conf.set("general", "dbpath", self.path)
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")
conf.set("guard", "enabled", "true") conf.set("guard", "enabled", "true")
conf.set("guard", "ratelimit", str(ratelimit)) conf.set("guard", "ratelimit", str(ratelimit))
@ -48,7 +47,7 @@ class TestGuard(unittest.TestCase):
class App(Isso, core.Mixin): class App(Isso, core.Mixin):
pass pass
app = App(conf) app = App(conf, connection=self.connection)
app.wsgi_app = FakeIP(app.wsgi_app, ip) app.wsgi_app = FakeIP(app.wsgi_app, ip)
return Client(app, Response) return Client(app, Response)

View File

@ -7,12 +7,11 @@ try:
except ImportError: except ImportError:
import unittest import unittest
import tempfile
from os.path import join, dirname from os.path import join, dirname
from isso import config from isso import config
from isso.db import SQLite3 from isso.db import SQLite3, Adapter
from isso.migrate import Disqus, WordPress, autodetect from isso.migrate import Disqus, WordPress, autodetect
conf = config.new({ conf = config.new({
@ -28,9 +27,8 @@ class TestMigration(unittest.TestCase):
def test_disqus(self): def test_disqus(self):
xml = join(dirname(__file__), "disqus.xml") xml = join(dirname(__file__), "disqus.xml")
xxx = tempfile.NamedTemporaryFile()
db = SQLite3(xxx.name, conf) db = Adapter(SQLite3(":memory:"), conf)
Disqus(db, xml).migrate() Disqus(db, xml).migrate()
self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 2) self.assertEqual(len(db.execute("SELECT id FROM comments").fetchall()), 2)
@ -50,9 +48,8 @@ class TestMigration(unittest.TestCase):
def test_wordpress(self): def test_wordpress(self):
xml = join(dirname(__file__), "wordpress.xml") xml = join(dirname(__file__), "wordpress.xml")
xxx = tempfile.NamedTemporaryFile()
db = SQLite3(xxx.name, conf) db = Adapter(SQLite3(":memory:"), conf)
WordPress(db, xml).migrate() WordPress(db, xml).migrate()
self.assertEqual(db.threads["/2014/test/"]["title"], "Hello, World…") self.assertEqual(db.threads["/2014/test/"]["title"], "Hello, World…")

View File

@ -3,7 +3,6 @@ from __future__ import unicode_literals
import os import os
import json import json
import tempfile
try: try:
import unittest2 as unittest import unittest2 as unittest
@ -12,7 +11,7 @@ except ImportError:
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from isso import Isso, core, config, dist from isso import Isso, cache, core, config, dist
from isso.utils import http from isso.utils import http
from fixtures import curl, loads, FakeIP, JSONClient from fixtures import curl, loads, FakeIP, JSONClient
@ -22,22 +21,17 @@ http.curl = curl
class TestVote(unittest.TestCase): class TestVote(unittest.TestCase):
def setUp(self): def setUp(self):
self.path = tempfile.NamedTemporaryFile().name
def makeClient(self, ip):
conf = config.load(os.path.join(dist.location, "isso", "defaults.ini")) conf = config.load(os.path.join(dist.location, "isso", "defaults.ini"))
conf.set("general", "dbpath", self.path)
conf.set("guard", "enabled", "off") conf.set("guard", "enabled", "off")
conf.set("hash", "algorithm", "none") conf.set("hash", "algorithm", "none")
class App(Isso, core.Mixin): class App(Isso, core.Mixin):
pass pass
app = App(conf) self.app = App(conf)
app.wsgi_app = FakeIP(app.wsgi_app, ip)
return JSONClient(app, Response) def makeClient(self, ip):
return JSONClient(FakeIP(self.app.wsgi_app, ip), Response)
def testZeroLikes(self): def testZeroLikes(self):

View File

@ -3,7 +3,7 @@
def _TypeError(expected, val): def _TypeError(expected, val):
if isinstance(expected, (list, tuple)): if isinstance(expected, (list, tuple)):
expected = ", ".join(expected.__name__) expected = ", ".join(ex.__name__ for ex in expected)
else: else:
expected = expected.__name__ expected = expected.__name__
return TypeError("Expected {0}, not {1}".format( return TypeError("Expected {0}, not {1}".format(
@ -11,5 +11,14 @@ def _TypeError(expected, val):
def require(val, expected): def require(val, expected):
"""Assure that :param val: is an instance of :param expected: or raise a
:exception TypeError: indicating what's expected.
>>> require(23, int)
>>> require(None, bool)
Traceback (most recent call last):
...
TypeError: Expected bool, not NoneType
"""
if not isinstance(val, expected): if not isinstance(val, expected):
raise _TypeError(expected, val) raise _TypeError(expected, val)

View File

@ -1,5 +1,7 @@
# -*- encoding: utf-8 -*- # -*- encoding: utf-8 -*-
from __future__ import unicode_literals
import re import re
import cgi import cgi
import time import time
@ -195,7 +197,7 @@ class API(object):
rv["text"] = self.isso.render(rv["text"]) rv["text"] = self.isso.render(rv["text"])
rv["hash"] = self.hash(rv['email'] or rv['remote_addr']) rv["hash"] = self.hash(rv['email'] or rv['remote_addr'])
self.cache.set('hash', (rv['email'] or rv['remote_addr']).encode('utf-8'), rv['hash']) self.cache.set('hash', (rv['email'] or rv['remote_addr']), rv['hash'])
for key in set(rv.keys()) - API.FIELDS: for key in set(rv.keys()) - API.FIELDS:
rv.pop(key) rv.pop(key)
@ -286,7 +288,7 @@ class API(object):
if item is None: if item is None:
raise NotFound raise NotFound
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8')) self.cache.delete('hash', (item['email'] or item['remote_addr']))
with self.isso.lock: with self.isso.lock:
rv = self.comments.delete(id) rv = self.comments.delete(id)
@ -337,7 +339,7 @@ class API(object):
else: else:
with self.isso.lock: with self.isso.lock:
self.comments.delete(id) self.comments.delete(id)
self.cache.delete('hash', (item['email'] or item['remote_addr']).encode('utf-8')) self.cache.delete('hash', (item['email'] or item['remote_addr']))
self.signal("comments.delete", id) self.signal("comments.delete", id)
return Response("Yo", 200) return Response("Yo", 200)
@ -422,11 +424,11 @@ class API(object):
for item in fetched_list: for item in fetched_list:
key = item['email'] or item['remote_addr'] key = item['email'] or item['remote_addr']
val = self.cache.get('hash', key.encode('utf-8')) val = self.cache.get('hash', key)
if val is None: if val is None:
val = self.hash(key) val = self.hash(key)
self.cache.set('hash', key.encode('utf-8'), val) self.cache.set('hash', key, val)
item['hash'] = val item['hash'] = val

View File

@ -4,8 +4,8 @@
[general] [general]
# file location to the SQLite3 database, highly recommended to change this # file location to the SQLite3 database, highly recommended to change this
# location to a non-temporary location! # location to a persistent location, e.g. /var/lib/isso/comments.db.
dbpath = /tmp/comments.db dbpath = :memory:
# required to dispatch multiple websites, not used otherwise. # required to dispatch multiple websites, not used otherwise.
name = name =