[Author Prev][Author Next][Thread Prev][Thread Next][Author Index][Thread Index]

[or-cvs] [bridgedb/master] Switch the storage backend to use sqlite3.



Author: Nick Mathewson <nickm@xxxxxxxxxxxxxx>
Date: Thu, 1 Oct 2009 15:01:57 -0400
Subject: Switch the storage backend to use sqlite3.
Commit: 59d34d897be6b5d02536945e621c4465c94279b2

Still unimplemented:
  - cleaning out emailedbridges table
  - rate-limiting by email addr
---
 lib/bridgedb/Bridges.py |  109 +++++++---------------------------------
 lib/bridgedb/Dist.py    |   28 ++++++----
 lib/bridgedb/Main.py    |   23 ++------
 lib/bridgedb/Storage.py |  129 ++++++++++++++++++++++++++++++++++++++++------
 lib/bridgedb/Tests.py   |   71 +++++++++++++++++++++++++-
 lib/bridgedb/Time.py    |   10 ++--
 6 files changed, 227 insertions(+), 143 deletions(-)

diff --git a/lib/bridgedb/Bridges.py b/lib/bridgedb/Bridges.py
index 8fc5cb7..9d588da 100644
--- a/lib/bridgedb/Bridges.py
+++ b/lib/bridgedb/Bridges.py
@@ -16,6 +16,8 @@ import sha
 import socket
 import time
 
+import bridgedb.Storage
+
 HEX_FP_LEN = 40
 ID_LEN = 20
 
@@ -182,7 +184,6 @@ def parseDescFile(f, bridge_purpose='bridge'):
 
 def parseStatusFile(f):
     """DOCDOC"""
-    result = None
     ID = None
     for line in f:
         line = line.strip()
@@ -347,64 +348,6 @@ class BridgeRing(BridgeHolder):
 
         return self.bridgesByID.get(fp)
 
-class LogDB:
-    """Wraps a database object and records all modifications to a
-       human-readable logfile."""
-    def __init__(self, kwd, db, logfile):
-        if kwd:
-            self._kwd = "%s: "%kwd
-        else:
-            self._kwd = ""
-        self._db = db
-        self._logfile = logfile
-    def __delitem__(self, k):
-        self._logfile.write("%s: del[%r]\n"%(self._kwd, k))
-        del self._db[k]
-    def __setitem__(self, k, v):
-        self._logfile.write("%s: [%r] = [%r]\n"%(self._kwd, k, v))
-        self._db[k] = v
-    def setdefault(self, k, v):
-        try:
-            return self._db[k]
-        except KeyError:
-            self._logfile.write("%s[%r] = [%r]\n"%(self._kwd, k, v))
-            self._db[k] = v
-            return v
-    def __len__(self):
-        return len(self._db)
-    def __getitem__(self, k):
-        return self._db[k]
-    def has_key(self, k):
-        return self._db.has_key(k)
-    def get(self, k, v=None):
-        return self._db.get(k, v)
-    def keys(self):
-        return self._db.keys()
-
-
-class PrefixStore:
-    """Wraps a database object and prefixes the keys in all requests with
-       'prefix'.  This is used to multiplex several key->value mappings
-       onto a single database."""
-    def __init__(self, store, prefix):
-        self._d = store
-        self._p = prefix
-    def __setitem__(self, k, v):
-        self._d[self._p+k] = v
-    def __delitem__(self, k):
-        del self._d[self._p+k]
-    def __getitem__(self, k):
-        return self._d[self._p+k]
-    def has_key(self, k):
-        return self._d.has_key(self._p+k)
-    def get(self, k, v=None):
-        return self._d.get(self._p+k, v)
-    def setdefault(self, k, v):
-        return self._d.setdefault(self._p+k, v)
-    def keys(self):
-        n = len(self._p)
-        return [ k[n:] for k in self._d.keys() if k.startswith(self._p) ]
-
 class FixedBridgeSplitter(BridgeHolder):
     """A bridgeholder that splits bridges up based on an hmac and assigns
        them to several sub-bridgeholders with equal probability.
@@ -446,31 +389,13 @@ class UnallocatedHolder(BridgeHolder):
     def __len__(self):
         return 0
 
-class BridgeTracker:
-    """A stats tracker that records when we first saw and most recently
-       saw each bridge.
-    """
-    def __init__(self, firstSeenStore, lastSeenStore):
-        self.firstSeenStore = firstSeenStore
-        self.lastSeenStore = lastSeenStore
-
-    def insert(self, bridge):
-        #XXXX is this really sane?  Should we track minutes? hours?
-        now = time.strftime("%Y-%m-%d %H:%M", time.gmtime())
-        bridgeID = bridge.getID()
-        # The last-seen time always gets updated
-        self.lastSeenStore[bridgeID] = now
-        # The first-seen time only gets updated if it wasn't already set.
-        self.firstSeenStore.setdefault(bridgeID, now)
-
 class BridgeSplitter(BridgeHolder):
     """A BridgeHolder that splits incoming bridges up based on an hmac,
        and assigns them to sub-bridgeholders with different probabilities.
        Bridge-to-bridgeholder associations are recorded in a store.
     """
-    def __init__(self, key, store):
+    def __init__(self, key):
         self.hmac = get_hmac_fn(key, hex=True)
-        self.store = store
         self.ringsByName = {}
         self.totalP = 0
         self.pValues = []
@@ -508,24 +433,26 @@ class BridgeSplitter(BridgeHolder):
 
     def insert(self, bridge):
         assert self.rings
+        db = bridgedb.Storage.getDB()
+
         for s in self.statsHolders:
             s.insert(bridge)
         if bridge.running == False or bridge.running == None:
             return
 
         bridgeID = bridge.getID()
-        ringname = self.store.get(bridgeID, "")
+
+        # Determine which ring to put this bridge in if we haven't seen it
+        # before.
+        pos = self.hmac(bridgeID)
+        n = int(pos[:8], 16) % self.totalP
+        pos = bisect.bisect_right(self.pValues, n) - 1
+        assert 0 <= pos < len(self.rings)
+        ringname = self.rings[pos]
+
+        ringname = db.insertBridgeAndGetRing(bridge, ringname, time.time())
+        db.commit()
+
         ring = self.ringsByName.get(ringname)
-        if ring is not None:
-            ring.insert(bridge)
-        else:
-            pos = self.hmac(bridgeID)
-            n = int(pos[:8], 16) % self.totalP
-            pos = bisect.bisect_right(self.pValues, n) - 1
-            assert 0 <= pos < len(self.rings)
-            ringname = self.rings[pos]
-            ring = self.ringsByName.get(ringname)
-            if ring.assignmentsArePersistent():
-                self.store[bridgeID] = ringname
-            ring.insert(bridge)
+        ring.insert(bridge)
 
diff --git a/lib/bridgedb/Dist.py b/lib/bridgedb/Dist.py
index f00d918..701e82b 100644
--- a/lib/bridgedb/Dist.py
+++ b/lib/bridgedb/Dist.py
@@ -7,9 +7,11 @@ This module has functions to decide which bridges to hand out to whom.
 """
 
 import bridgedb.Bridges
+import bridgedb.Storage
 
 import logging
 import re
+import time
 
 def uniformMap(ip):
     """Map an IP to an arbitrary 'area' string, such that any two /24 addresses
@@ -205,7 +207,7 @@ class EmailBasedDistributor(bridgedb.Bridges.BridgeHolder):
     ##   store -- a database object to remember what we've given to whom.
     ##   domainmap -- a map from lowercase domains that we support mail from
     ##       to their canonical forms.
-    def __init__(self, key, store, domainmap, domainrules,
+    def __init__(self, key, domainmap, domainrules,
                  answerParameters=None):
         key1 = bridgedb.Bridges.get_hmac(key, "Map-Addresses-To-Ring")
         self.emailHmac = bridgedb.Bridges.get_hmac_fn(key1, hex=False)
@@ -214,7 +216,6 @@ class EmailBasedDistributor(bridgedb.Bridges.BridgeHolder):
         self.ring = bridgedb.Bridges.BridgeRing(key2, answerParameters)
         self.ring.name = "email ring"
         # XXXX clear the store when the period rolls over!
-        self.store = store
         self.domainmap = domainmap
         self.domainrules = domainrules
 
@@ -232,29 +233,34 @@ class EmailBasedDistributor(bridgedb.Bridges.BridgeHolder):
                be any string, so long as it changes with every period.
            N -- the number of bridges to try to give back.
         """
-        try: 
+        try:
           emailaddress = normalizeEmail(emailaddress, self.domainmap,
                                       self.domainrules)
         except BadEmail:
           return [] #XXXX log the exception
         if emailaddress is None:
             return [] #XXXX raise an exception.
-        if self.store.has_key(emailaddress):
-            result = []
-            ids_str = self.store[emailaddress]
+
+        db = bridgedb.Storage.getDB()
+
+        ids = db.getEmailedBridges(emailaddress)
+
+        if ids:
             logging.info("We've seen %r before. Sending the same bridges"
                          " as last time", emailaddress)
-            for ident in bridgedb.Bridges.chopString(ids_str,
-                                                     bridgedb.Bridges.ID_LEN):
-                b = self.ring.getBridgeByID(ident)
+            result = []
+            for fp in ids:
+                b = self.ring.getBridgeByID(bridgedb.Bridges.fromHex(fp))
                 if b != None:
                     result.append(b)
             return result
 
         pos = self.emailHmac("<%s>%s" % (epoch, emailaddress))
         result = self.ring.getBridges(pos, N)
-        memo = "".join(b.getID() for b in result)
-        self.store[emailaddress] = memo
+
+        db.addEmailedBridges(emailaddress, time.time(),
+                             [b.fingerprint for b in result])
+        db.commit()
         return result
 
     def __len__(self):
diff --git a/lib/bridgedb/Main.py b/lib/bridgedb/Main.py
index f7339cf..79f37ad 100644
--- a/lib/bridgedb/Main.py
+++ b/lib/bridgedb/Main.py
@@ -6,7 +6,6 @@
 This module sets up a bridgedb and starts the servers running.
 """
 
-import anydbm
 import os
 import signal
 import sys
@@ -18,6 +17,7 @@ import bridgedb.Bridges as Bridges
 import bridgedb.Dist as Dist
 import bridgedb.Time as Time
 import bridgedb.Server as Server
+import bridgedb.Storage
 
 class Conf:
     """A configuration object.  Holds unvalidated attributes.
@@ -220,11 +220,9 @@ def startup(cfg):
     key = getKey(cfg.MASTER_KEY_FILE)
 
     # Initialize our DB file.
-    dblogfile = None
-    baseStore = store = anydbm.open(cfg.DB_FILE, "c", 0600)
-    if cfg.DB_LOG_FILE:
-        dblogfile = open(cfg.DB_LOG_FILE, "a+", 0)
-        store = Bridges.LogDB(None, store, dblogfile)
+    db = bridgedb.Storage.Database(cfg.DB_FILE+".sqlite",
+                                   cfg.DB_FILE)
+    bridgedb.Storage.setGlobalDB(db)
 
     # Get a proxy list.
     proxyList = ProxyCategory()
@@ -232,8 +230,7 @@ def startup(cfg):
 
     # Create a BridgeSplitter to assign the bridges to the different
     # distributors.
-    splitter = Bridges.BridgeSplitter(Bridges.get_hmac(key, "Splitter-Key"),
-                                      Bridges.PrefixStore(store, "sp|"))
+    splitter = Bridges.BridgeSplitter(Bridges.get_hmac(key, "Splitter-Key"))
 
     # Create ring parameters.
     forcePorts = getattr(cfg, "FORCE_PORTS")
@@ -264,7 +261,6 @@ def startup(cfg):
             cfg.EMAIL_DOMAIN_MAP[d] = d
         emailDistributor = Dist.EmailBasedDistributor(
             Bridges.get_hmac(key, "Email-Dist-Key"),
-            Bridges.PrefixStore(store, "em|"),
             cfg.EMAIL_DOMAIN_MAP.copy(),
             cfg.EMAIL_DOMAIN_RULES.copy(),
             answerParameters=ringParams)
@@ -277,11 +273,6 @@ def startup(cfg):
                          "unallocated",
                          cfg.RESERVED_SHARE)
 
-    # Add a tracker to tell us how often we've seen various bridges.
-    stats = Bridges.BridgeTracker(Bridges.PrefixStore(store, "fs|"),
-                                  Bridges.PrefixStore(store, "ls|"))
-    splitter.addTracker(stats)
-
     # Make the parse-bridges function get re-called on SIGHUP.
     def reload():
         logging.info("Caught SIGHUP")
@@ -315,9 +306,7 @@ def startup(cfg):
         logging.info("Starting reactors.")
         Server.runServers()
     finally:
-        baseStore.close()
-        if dblogfile is not None:
-            dblogfile.close()
+        db.close()
         if cfg.PIDFILE:
             os.unlink(cfg.PIDFILE)
 
diff --git a/lib/bridgedb/Storage.py b/lib/bridgedb/Storage.py
index 07ec8ba..d3e0e22 100644
--- a/lib/bridgedb/Storage.py
+++ b/lib/bridgedb/Storage.py
@@ -6,13 +6,19 @@ import os
 import logging
 import bridgedb.Bridges
 import binascii
+import sqlite3
+import time
 
 toHex = binascii.b2a_hex
 fromHex = binascii.a2b_hex
+HEX_ID_LEN = 40
 
 def _escapeValue(v):
     return "'%s'" % v.replace("'", "''")
 
+def timeToStr(t):
+    return time.strftime("%Y-%m-%d %H:%M", time.gmtime(t))
+
 class SqliteDict:
     """
        A SqliteDict wraps a SQLite table and makes it look like a
@@ -61,7 +67,7 @@ class SqliteDict:
             raise KeyError(k)
         else:
             return val[0]
-    def has_key(self):
+    def has_key(self, k):
         self._cursor.execute(self._getStmt, (k,))
         return self._cursor.rowcount != 0
     def get(self, k, v=None):
@@ -86,6 +92,7 @@ class SqliteDict:
     def rollback(self):
         self._conn.rollback()
 
+
 #  The old DB system was just a key->value mapping DB, with special key
 #  prefixes to indicate which database they fell into.
 #
@@ -108,8 +115,9 @@ SCHEMA1_SCRIPT = """
 
  CREATE TABLE Bridges (
      id INTEGER PRIMARY KEY NOT NULL,
-     hex_key, -- index this.
+     hex_key,
      address,
+     or_port,
      distributor,
      first_seen,
      last_seen
@@ -126,23 +134,101 @@ SCHEMA1_SCRIPT = """
  CREATE INDEX EmailedBridgesEmailIndex ON EmailedBridges ( email );
 """
 
-def openDatabase(sqlite_file, db_file):
+
+class Database:
+    def __init__(self, sqlite_fname, db_fname=None):
+        if db_fname is None:
+            self._conn = openDatabase(sqlite_fname)
+        else:
+            self._conn = openOrConvertDatabase(sqlite_fname, db_fname)
+        self._cur = self._conn.cursor()
+
+    def commit(self):
+        self._conn.commit()
+
+    def close(self):
+        self._cur.close()
+        self._conn.close()
+
+    def insertBridgeAndGetRing(self, bridge, setRing, seenAt):
+        '''updates info about bridge, setting ring to setRing if none was set.
+           Returns the bridge's ring.
+        '''
+        cur = self._cur
+
+        t = timeToStr(seenAt)
+        h = bridge.fingerprint
+        assert len(h) == HEX_ID_LEN
+
+        cur.execute("SELECT id, distributor "
+                    "FROM Bridges WHERE hex_key = ?", (h,))
+        v = cur.fetchone()
+        if v is not None:
+            idx, ring = v
+            # Update last_seen and address.
+            cur.execute("UPDATE Bridges SET address = ?, or_port = ?, "
+                        "last_seen = ? WHERE id = ?",
+                        (bridge.ip, bridge.orport, timeToStr(seenAt), idx))
+            return ring
+        else:
+            # Insert it.
+            cur.execute("INSERT INTO Bridges (hex_key, address, or_port, "
+                        "distributor, first_seen, last_seen) "
+                        "VALUES (?, ?, ?, ?, ?, ?)",
+                        (h, bridge.ip, bridge.orport, setRing, t, t))
+            return setRing
+
+    def cleanEmailedBridges(self, expireBefore):
+        cur = self._cur
+        t = timeToStr(expireBefore)
+
+        cur.execute("DELETE FROM Bridges WHERE when_mailed < ?", t);
+
+    def getEmailedBridges(self, addr):
+        cur = self._cur
+        cur.execute("SELECT hex_key FROM EmailedBridges, Bridges WHERE "
+                    "email = ? AND Bridges.id = EmailedBridges.id", (addr,))
+        return [ hk for hk, in cur.fetchall() ]
+
+    def addEmailedBridges(self, addr, whenMailed, bridgeKeys):
+        cur = self._cur
+        t = timeToStr(whenMailed)
+        for k in bridgeKeys:
+            assert(len(k))==HEX_ID_LEN
+        cur.executemany("INSERT INTO EmailedBridges (email,when_mailed,id) "
+                        "SELECT ?,?,id FROM Bridges WHERE hex_key = ?",
+                        [(addr,t,k) for k in bridgeKeys])
+
+def openDatabase(sqlite_file):
+    conn = sqlite3.Connection(sqlite_file)
+    cur = conn.cursor()
+    try:
+        try:
+            cur.execute("SELECT value FROM Config WHERE key = 'schema-version'")
+            val, = cur.fetchone()
+            if val != 1:
+                logging.warn("Unknown schema version %s in database.", val)
+        except sqlite3.OperationalError:
+            logging.warn("No Config table found in DB; creating tables")
+            cur.executescript(SCHEMA1_SCRIPT)
+            conn.commit()
+    finally:
+        cur.close()
+    return conn
+
+
+def openOrConvertDatabase(sqlite_file, db_file):
     """Open a sqlite database, converting it from a db file if needed."""
     if os.path.exists(sqlite_file):
-        conn = sqlite3.Connection(sqlite_file)
-        cur = conn.cursor()
-        cur.execute("SELECT value FROM Config WHERE key = 'schema-version'")
-        val, = cur.fetchone()
-        if val != 1:
-            logging.warn("Unknown schema version %s in database.", val)
-        cur.close()
-        return conn
+        return openDatabase(sqlite_file)
 
     conn = sqlite3.Connection(sqlite_file)
     cur = conn.cursor()
     cur.executescript(SCHEMA1_SCRIPT)
     conn.commit()
 
+    import anydbm
+
     try:
         db = anydbm.open(db_file, 'r')
     except anydbm.error:
@@ -154,22 +240,22 @@ def openDatabase(sqlite_file, db_file):
             if k.startswith("sp|"):
                 assert len(k) == 23
                 cur.execute("INSERT INTO Bridges ( hex_key, distributor ) "
-                            "VALUES (%s %s)", (toHex(k[3:]),v))
+                            "VALUES (?, ?)", (toHex(k[3:]),v))
         for k in db.keys():
             v = db[k]
             if k.startswith("fs|"):
                 assert len(k) == 23
-                cur.execute("UPDATE Bridges SET first_seen = %s "
-                            "WHERE hex_key = %s", (v, k[3:]))
+                cur.execute("UPDATE Bridges SET first_seen = ? "
+                            "WHERE hex_key = ?", (v, toHex(k[3:])))
             elif k.startswith("ls|"):
                 assert len(k) == 23
-                cur.execute("UPDATE Bridges SET last_seen = %s "
-                            "WHERE hex_key = %s", (v, toHex(k[3:])))
+                cur.execute("UPDATE Bridges SET last_seen = ? "
+                            "WHERE hex_key = ?", (v, toHex(k[3:])))
             elif k.startswith("em|"):
                 keys = list(toHex(i) for i in
                     bridgedb.Bridges.chopString(v, bridgedb.Bridges.ID_LEN))
                 cur.executemany("INSERT INTO EmailedBridges ( email, id ) "
-                                "SELECT %s, id FROM Bridges WHERE hex_key = %s",
+                                "SELECT ?, id FROM Bridges WHERE hex_key = ?",
                                 [(k[3:],i) for i in keys])
             elif k.startswith("sp|"):
                 pass
@@ -183,3 +269,12 @@ def openDatabase(sqlite_file, db_file):
     conn.commit()
     return conn
 
+_THE_DB = None
+
+def setGlobalDB(db):
+    global _THE_DB
+    _THE_DB = db
+
+def getDB(db):
+    return _THE_DB
+
diff --git a/lib/bridgedb/Tests.py b/lib/bridgedb/Tests.py
index 865c91e..826b3c4 100644
--- a/lib/bridgedb/Tests.py
+++ b/lib/bridgedb/Tests.py
@@ -9,6 +9,7 @@ import sqlite3
 import tempfile
 import unittest
 import warnings
+import time
 
 import bridgedb.Bridges
 import bridgedb.Main
@@ -82,7 +83,7 @@ class IPBridgeDistTests(unittest.TestCase):
             self.assertEquals(len(fps), 5)
             self.assertTrue(count >= 1)
 
-class StorageTests(unittest.TestCase):
+class DictStorageTests(unittest.TestCase):
     def setUp(self):
         self.fd, self.fname = tempfile.mkstemp()
         self.conn = sqlite3.Connection(self.fname)
@@ -128,11 +129,77 @@ class StorageTests(unittest.TestCase):
         self.assertEquals(d.setdefault("yo","bye"), "bye")
         self.assertEquals(d['yo'], "bye")
 
+class SQLStorageTests(unittest.TestCase):
+    def setUp(self):
+        self.fd, self.fname = tempfile.mkstemp()
+        self.db = bridgedb.Storage.Database(self.fname)
+        self.cur = self.db._conn.cursor()
+
+    def tearDown(self):
+        self.db.close()
+        os.close(self.fd)
+        os.unlink(self.fname)
+
+    def testBridgeStorage(self):
+        db = self.db
+        B = bridgedb.Bridges.Bridge
+        t = time.time()
+        cur = self.cur
+
+        k1 = "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb"
+        k2 = "abababababababababababababababababababab"
+        k3 = "cccccccccccccccccccccccccccccccccccccccc"
+        b1 = B("serv1", "1.2.3.4", 999, fingerprint=k1)
+        b1_v2 = B("serv1", "1.2.3.5", 9099, fingerprint=k1)
+        b2 = B("serv2", "2.3.4.5", 9990, fingerprint=k2)
+        b3 = B("serv3", "2.3.4.6", 9008, fingerprint=k3)
+
+        r = db.insertBridgeAndGetRing(b1, "ring1", t)
+        self.assertEquals(r, "ring1")
+        r = db.insertBridgeAndGetRing(b1, "ring10", t+500)
+        self.assertEquals(r, "ring1")
+
+        cur.execute("SELECT distributor, address, or_port, first_seen, "
+                    "last_seen FROM Bridges WHERE hex_key = ?", (k1,))
+        v = cur.fetchone()
+        self.assertEquals(v,
+                          ("ring1", "1.2.3.4", 999,
+                           bridgedb.Storage.timeToStr(t),
+                           bridgedb.Storage.timeToStr(t+500)))
+
+        r = db.insertBridgeAndGetRing(b1_v2, "ring99", t+800)
+        self.assertEquals(r, "ring1")
+        cur.execute("SELECT distributor, address, or_port, first_seen, "
+                    "last_seen FROM Bridges WHERE hex_key = ?", (k1,))
+        v = cur.fetchone()
+        self.assertEquals(v,
+                          ("ring1", "1.2.3.5", 9099,
+                           bridgedb.Storage.timeToStr(t),
+                           bridgedb.Storage.timeToStr(t+800)))
+
+        db.insertBridgeAndGetRing(b2, "ring2", t)
+        db.insertBridgeAndGetRing(b3, "ring3", t)
+
+        cur.execute("SELECT COUNT(distributor) FROM Bridges")
+        v = cur.fetchone()
+        self.assertEquals(v, (3,))
+
+        r = db.getEmailedBridges("abc@xxxxxxxxxxx")
+        self.assertEquals(r, [])
+        db.addEmailedBridges("abc@xxxxxxxxxxx", t, [k1,k2])
+        db.addEmailedBridges("def@xxxxxxxxxxx", t+1000, [k2,k3])
+        r = db.getEmailedBridges("abc@xxxxxxxxxxx")
+        self.assertEquals(sorted(r), sorted([k1,k2]))
+        r = db.getEmailedBridges("def@xxxxxxxxxxx")
+        self.assertEquals(sorted(r), sorted([k2,k3]))
+        r = db.getEmailedBridges("ghi@xxxxxxxxxxx")
+        self.assertEquals(r, [])
+
 def testSuite():
     suite = unittest.TestSuite()
     loader = unittest.TestLoader()
 
-    for klass in [ IPBridgeDistTests, StorageTests ]:
+    for klass in [ IPBridgeDistTests, DictStorageTests, SQLStorageTests ]:
         suite.addTest(loader.loadTestsFromTestCase(klass))
 
     for module in [ bridgedb.Bridges,
diff --git a/lib/bridgedb/Time.py b/lib/bridgedb/Time.py
index ffc73e0..6c263c7 100644
--- a/lib/bridgedb/Time.py
+++ b/lib/bridgedb/Time.py
@@ -35,7 +35,7 @@ class IntervalSchedule:
         self.itype = it
         self.count = count
 
-    def _intervalStart(self, when):
+    def intervalStart(self, when):
         """Return the time (as an int) of the start of the interval containing
            'when'."""
         if self.itype == 'month':
@@ -72,11 +72,11 @@ class IntervalSchedule:
             month = n%12 + 1
             return "%04d-%02d" % (n // 12, month)
         elif self.itype == 'day':
-            when = self._intervalStart(when) + 7200 #slop
+            when = self.intervalStart(when) + 7200 #slop
             tm = time.gmtime(when)
             return "%04d-%02d-%02d" % (tm.tm_year, tm.tm_mon, tm.tm_mday)
         elif self.itype == 'hour':
-            when = self._intervalStart(when) + 120 #slop
+            when = self.intervalStart(when) + 120 #slop
             tm = time.gmtime(when)
             return "%04d-%02d-%02d %02d" % (tm.tm_year, tm.tm_mon, tm.tm_mday,
                                             tm.tm_hour)
@@ -93,7 +93,7 @@ class IntervalSchedule:
             tm = (n // 12, month+self.count, 1, 0,0,0)
             return calendar.timegm(tm)
         elif self.itype == 'day':
-            return self._intervalStart(when) + 86400 * self.count
+            return self.intervalStart(when) + 86400 * self.count
         elif self.itype == 'hour':
-            return self._intervalStart(when) + 3600 * self.count
+            return self.intervalStart(when) + 3600 * self.count
 
-- 
1.5.6.5