← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/python-oops-datedir-repo/hashing into lp:python-oops-datedir-repo

 

Robert Collins has proposed merging lp:~lifeless/python-oops-datedir-repo/hashing into lp:python-oops-datedir-repo.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lifeless/python-oops-datedir-repo/hashing/+merge/78039

Adds:
 - bson serializer and deserializer
 - generic deserializer
 - hash based id allocation and filename allocation
-- 
https://code.launchpad.net/~lifeless/python-oops-datedir-repo/hashing/+merge/78039
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/python-oops-datedir-repo/hashing into lp:python-oops-datedir-repo.
=== added file 'MANIFEST.in'
=== modified file 'NEWS'
--- NEWS	2011-09-18 22:44:43 +0000
+++ NEWS	2011-10-04 05:12:28 +0000
@@ -6,6 +6,25 @@
 NEXT
 ----
 
+0.0.7
+-----
+
+This release adds a bson serializer and changes the default for DateDirRepo to
+use that new serializer. Be sure that any code wanted to read your OOPSes is
+updated to use the new serializer or serializer_bson modules when reading.
+
+* Added a serializer_bson module containing a bson serializer.
+  (Robert Collins)
+
+* Added a serializer module which will autodetect bson or rfc822 OOPSes (needed
+  for legacy situations - recommend using the specific serializer if known.
+  (Robert Collins)
+
+* The DateDirRepo can now generate bson serialized OOPSes. (Robert Collins)
+
+* The DateDirRepo can now assign ids using a hash function rather than the (non
+  concurrent-safe) monotonic-id approach. (Robert Collins)
+
 0.0.6
 -----
 

=== modified file 'oops_datedir_repo/__init__.py'
--- oops_datedir_repo/__init__.py	2011-09-18 22:44:43 +0000
+++ oops_datedir_repo/__init__.py	2011-10-04 05:12:28 +0000
@@ -25,7 +25,7 @@
 # established at this point, and setup.py will use a version of next-$(revno).
 # If the releaselevel is 'final', then the tarball will be major.minor.micro.
 # Otherwise it is major.minor.micro~$(revno).
-__version__ = (0, 0, 6, 'beta', 0)
+__version__ = (0, 0, 7, 'beta', 0)
 
 __all__ = [
     'DateDirRepo',

=== modified file 'oops_datedir_repo/repository.py'
--- oops_datedir_repo/repository.py	2011-08-16 02:21:20 +0000
+++ oops_datedir_repo/repository.py	2011-10-04 05:12:28 +0000
@@ -23,11 +23,13 @@
     ]
 
 import datetime
-import os
+from hashlib import md5
+import os.path
 import stat
 
 from pytz import utc
 
+import serializer_bson
 import serializer_rfc822
 from uniquefileallocator import UniqueFileAllocator
 
@@ -35,12 +37,34 @@
 class DateDirRepo:
     """Publish oopses to a date-dir repository."""
 
-    def __init__(self, error_dir, instance_id):
-        self.log_namer = UniqueFileAllocator(
-            output_root=error_dir,
-            log_type="OOPS",
-            log_subtype=instance_id,
-            )
+    def __init__(self, error_dir, instance_id=None, serializer=None):
+        """Create a DateDirRepo.
+
+        :param error_dir: The base directory to write OOPSes into. OOPSes are
+            written into a subdirectory this named after the date (e.g.
+            2011-12-30).
+        :param instance_id: If None, OOPS file names are named after the OOPS
+            id which is generated by hashing the serialized OOPS (without the
+            id field). Otherwise OOPS file names and ids are created by
+            allocating file names through a UniqueFileAllocator.
+            UniqueFileAllocator has significant performance and concurrency
+            limits and hash based naming is recommended.
+        :param serializer: If supplied should be the module (e.g.
+            oops_datedir_repo.serializer_rfc822) to use to serialize OOPSes.
+            Defaults to using serializer_bson.
+        """
+        if instance_id is not None:
+            self.log_namer = UniqueFileAllocator(
+                output_root=error_dir,
+                log_type="OOPS",
+                log_subtype=instance_id,
+                )
+        else:
+            self.log_namer = None
+            self.root = error_dir
+        if serializer is None:
+            serializer = serializer_bson
+        self.serializer = serializer
 
     def publish(self, report, now=None):
         """Write the report to disk.
@@ -52,9 +76,19 @@
             now = now.astimezone(utc)
         else:
             now = datetime.datetime.now(utc)
-        oopsid, filename = self.log_namer.newId(now)
+        # Don't mess with the original report:
+        report = dict(report)
+        if self.log_namer is not None:
+            oopsid, filename = self.log_namer.newId(now)
+        else:
+            report.pop('id', None)
+            md5hash = md5(serializer_bson.dumps(report)).hexdigest()
+            oopsid = 'OOPS-%s' % md5hash
+            prefix = os.path.join(self.root, now.strftime('%Y-%m-%d'))
+            os.makedirs(prefix)
+            filename = os.path.join(prefix, oopsid)
         report['id'] = oopsid
-        serializer_rfc822.write(report, open(filename, 'wb'))
+        self.serializer.write(report, open(filename, 'wb'))
         # Set file permission to: rw-r--r-- (so that reports from
         # umask-restricted services can be gathered by a tool running as
         # another user).

=== added file 'oops_datedir_repo/serializer.py'
--- oops_datedir_repo/serializer.py	1970-01-01 00:00:00 +0000
+++ oops_datedir_repo/serializer.py	2011-10-04 05:12:28 +0000
@@ -0,0 +1,55 @@
+# Copyright (c) 2011, Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Read from any known serializer.
+
+Where possible using the specific known serializer is better as it is more
+efficient and won't suffer false positives if two serializations happen to pun
+with each other (unlikely though that is).
+
+Typical usage:
+    >>> fp = file('an-oops', 'rb')
+    >>> report = serializer.read(fp)
+
+See the serializer_rfc822 and serializer_bson modules for information about
+serializing OOPS reports by hand. Generally just using the DateDirRepo.publish
+method is all that is needed.
+"""
+
+
+__all__ = [
+    'read',
+    ]
+
+from StringIO import StringIO
+
+from oops_datedir_repo import (
+    serializer_bson,
+    serializer_rfc822,
+    )
+
+
+def read(fp):
+    """Deserialize an OOPS from a bson or rfc822 message.
+    
+    The whole file is read regardless of the OOPS format.
+    """
+    # Deal with no-rewindable file pointers.
+    content = fp.read()
+    try:
+        return serializer_bson.read(StringIO(content))
+    except KeyError:
+        return serializer_rfc822.read(StringIO(content))

=== added file 'oops_datedir_repo/serializer_bson.py'
--- oops_datedir_repo/serializer_bson.py	1970-01-01 00:00:00 +0000
+++ oops_datedir_repo/serializer_bson.py	2011-10-04 05:12:28 +0000
@@ -0,0 +1,84 @@
+# Copyright (c) 2011, Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Read / Write an OOPS dict as a bson dict.
+
+This style of OOPS format is very extensible and maintains compatability with
+older rfc822 oops code: the previously mandatory keys are populated on read.
+
+Use of bson serializing is recommended.
+
+The reports this serializer handles always have the following variables (See
+the python-oops api docs for more information about these variables):
+
+* id: The name of this error report.
+* type: The type of the exception that occurred.
+* value: The value of the exception that occurred.
+* time: The time at which the exception occurred.
+* reporter: The reporting program.
+* topic: The identifier for the template/script that oopsed.
+* branch_nick: The branch nickname.
+* revno: The revision number of the branch.
+* tb_text: A text version of the traceback.
+* username: The user associated with the request.
+* url: The URL for the failed request.
+* req_vars: The request variables.
+* branch_nick: A name for the branch of code that was running when the report
+  was triggered.
+* revno: The revision that the branch was at.
+"""
+
+
+__all__ = [
+    'dumps',
+    'read',
+    'write',
+    ]
+
+__metaclass__ = type
+
+import datetime
+import logging
+import rfc822
+import re
+import urllib
+
+import bson
+import iso8601
+
+
+def read(fp):
+    """Deserialize an OOPS from a bson message."""
+    report = bson.loads(fp.read())
+    for key in (
+            'branch_nick', 'revno', 'type', 'value', 'time', 'topic',
+            'username', 'url'):
+        report.setdefault(key, None)
+    report.setdefault('duration', -1)
+    report.setdefault('req_vars', [])
+    report.setdefault('tb_text', '')
+    report.setdefault('timeline', [])
+    return report
+
+
+def dumps(report):
+    """Return a binary string representing report."""
+    return bson.dumps(report)
+
+
+def write(report, fp):
+    """Write report to fp."""
+    return fp.write(dumps(report))

=== modified file 'oops_datedir_repo/serializer_rfc822.py'
--- oops_datedir_repo/serializer_rfc822.py	2011-09-18 22:44:43 +0000
+++ oops_datedir_repo/serializer_rfc822.py	2011-10-04 05:12:28 +0000
@@ -117,10 +117,10 @@
                 if db_id is not None:
                     db_id = intern(db_id)  # This string is repeated lots.
                 statements.append(
-                    (int(start), int(end), db_id, statement))
+                    [int(start), int(end), db_id, statement])
             elif is_req_var(line):
                 key, value = line.split('=', 1)
-                req_vars.append((urllib.unquote(key), urllib.unquote(value)))
+                req_vars.append([urllib.unquote(key), urllib.unquote(value)])
             elif is_traceback(line):
                 break
 

=== modified file 'oops_datedir_repo/tests/__init__.py'
--- oops_datedir_repo/tests/__init__.py	2011-08-16 02:21:20 +0000
+++ oops_datedir_repo/tests/__init__.py	2011-10-04 05:12:28 +0000
@@ -23,6 +23,8 @@
     test_mod_names = [
         'repository',
         'uniquefileallocator',
+        'serializer',
+        'serializer_bson',
         'serializer_rfc822',
         ]
     return TestLoader().loadTestsFromNames(

=== modified file 'oops_datedir_repo/tests/test_repository.py'
--- oops_datedir_repo/tests/test_repository.py	2011-08-16 02:21:20 +0000
+++ oops_datedir_repo/tests/test_repository.py	2011-10-04 05:12:28 +0000
@@ -19,14 +19,19 @@
 __metaclass__ = type
 
 import datetime
+from hashlib import md5
 import os.path
 import stat
 
 from fixtures import TempDir
+import bson
 from pytz import utc
 import testtools
 
-from oops_datedir_repo import DateDirRepo
+from oops_datedir_repo import (
+    DateDirRepo,
+    serializer_bson,
+    )
 from oops_datedir_repo.uniquefileallocator import UniqueFileAllocator
 
 
@@ -59,3 +64,35 @@
     def test_sets_log_namer_to_a_UniqueFileAllocator(self):
         repo = DateDirRepo(self.useFixture(TempDir()).path, 'T')
         self.assertIsInstance(repo.log_namer, UniqueFileAllocator)
+
+    def test_default_serializer_bson(self):
+        repo = DateDirRepo(self.useFixture(TempDir()).path, 'T')
+        self.assertEqual(serializer_bson, repo.serializer)
+
+    def test_settable_serializer(self):
+        an_object = object()
+        repo = DateDirRepo(self.useFixture(TempDir()).path, 'T', an_object)
+        self.assertEqual(an_object, repo.serializer)
+
+    def test_no_instance_id_no_log_namer(self):
+        repo = DateDirRepo(self.useFixture(TempDir()).path)
+        self.assertEqual(None, repo.log_namer)
+
+    def test_publish_via_hash(self):
+        repo = DateDirRepo(self.useFixture(TempDir()).path)
+        now = datetime.datetime(2006, 04, 01, 00, 30, 00, tzinfo=utc)
+        report = {'time': now}
+        # NB: bson output depends on dict order, so the resulting hash can be
+        # machine specific. This is fine because its merely a strategy to get
+        # unique ids, and after creating the id it is preserved in what is
+        # written to disk: we don't need it to be deterministic across
+        # machines / instances.
+        expected_md5 = md5(serializer_bson.dumps(report)).hexdigest()
+        expected_id = "OOPS-%s" % expected_md5
+        self.assertEqual(expected_id, repo.publish(report, now))
+        # The file on disk should match the given id.
+        with open(repo.root + '/2006-04-01/' + expected_id, 'rb') as fp:
+            # And the content serialized should include the id.
+            self.assertEqual(
+                {'id': expected_id, 'time': now},
+                bson.loads(fp.read()))

=== added file 'oops_datedir_repo/tests/test_serializer.py'
--- oops_datedir_repo/tests/test_serializer.py	1970-01-01 00:00:00 +0000
+++ oops_datedir_repo/tests/test_serializer.py	2011-10-04 05:12:28 +0000
@@ -0,0 +1,70 @@
+# Copyright (c) 2011, Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the generic serialization support."""
+
+__metaclass__ = type
+
+import datetime
+import StringIO
+
+import bson
+from pytz import utc
+import testtools
+
+from oops_datedir_repo.serializer import read
+from oops_datedir_repo.serializer_bson import dumps
+from oops_datedir_repo.serializer_rfc822 import write
+
+
+class TestParsing(testtools.TestCase):
+
+    source_dict = {
+        'id': 'OOPS-A0001',
+        'type': 'NotFound',
+        'value': 'error message',
+        'time': datetime.datetime(2005, 4, 1, tzinfo=utc),
+        'topic': 'IFoo:+foo-template',
+        'tb_text': 'traceback\ntext\n',
+        'username': 'Sample User',
+        'url': 'http://localhost:9000/foo',
+        'duration': 42.0,
+        'req_vars': [
+            ['HTTP_USER_AGENT', 'Mozilla/5.0'],
+            ['HTTP_REFERER', 'http://localhost:9000/'],
+            ['name=foo', 'hello\nworld'],
+            ],
+        'timeline': [
+            [1, 5, 'store_a', 'SELECT 1'],
+            [5, 10, 'store_b', 'SELECT 2'],
+            ],
+        }
+    expected_dict = dict(source_dict)
+    # Unsupplied but filled on read
+    expected_dict['branch_nick'] = None
+    expected_dict['revno'] = None
+
+    def test_read_detect_rfc822(self):
+        source_file = StringIO.StringIO()
+        write(dict(self.source_dict), source_file)
+        source_file.seek(0)
+        self.assertEqual(self.expected_dict, read(source_file))
+
+    def test_read_detect_bson(self):
+        source_file = StringIO.StringIO()
+        source_file.write(dumps(dict(self.source_dict)))
+        source_file.seek(0)
+        self.assertEqual(self.expected_dict, read(source_file))

=== added file 'oops_datedir_repo/tests/test_serializer_bson.py'
--- oops_datedir_repo/tests/test_serializer_bson.py	1970-01-01 00:00:00 +0000
+++ oops_datedir_repo/tests/test_serializer_bson.py	2011-10-04 05:12:28 +0000
@@ -0,0 +1,121 @@
+# Copyright (c) 2011, Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for bson based serialization."""
+
+__metaclass__ = type
+
+import datetime
+import StringIO
+
+import bson
+from pytz import utc
+import testtools
+
+from oops_datedir_repo.serializer_bson import (
+    dumps,
+    read,
+    )
+
+
+class TestParsing(testtools.TestCase):
+
+    def test_read(self):
+        source_dict = {
+            'id': 'OOPS-A0001',
+            'type': 'NotFound',
+            'value': 'error message',
+            'time': datetime.datetime(2005, 4, 1, tzinfo=utc),
+            'topic': 'IFoo:+foo-template',
+            'tb_text': 'traceback\ntext\n',
+            'username': 'Sample User',
+            'url': 'http://localhost:9000/foo',
+            'duration': 42,
+            'req_vars': [
+                ['HTTP_USER_AGENT', 'Mozilla/5.0'],
+                ['HTTP_REFERER', 'http://localhost:9000/'],
+                ['name=foo', 'hello\nworld'],
+                ],
+            'timeline': [
+                [1, 5, 'store_a', 'SELECT 1'],
+                [5, 10, 'store_b', 'SELECT 2'],
+                ]
+            }
+        source_file = StringIO.StringIO(bson.dumps(source_dict))
+        expected_dict = dict(source_dict)
+        # Unsupplied but filled on read
+        expected_dict['branch_nick'] = None
+        expected_dict['revno'] = None
+        report = read(source_file)
+        self.assertEqual(expected_dict, report)
+
+    def test_minimal_oops(self):
+        # If we get a crazy-small oops, we can read it sensibly.  Because there
+        # is existing legacy code, all keys are filled in with None, [] rather
+        # than being empty.
+        source_dict = {
+            'id': 'OOPS-A0001',
+            }
+        source_file = StringIO.StringIO(bson.dumps(source_dict))
+        report = read(source_file)
+        self.assertEqual(report['id'], 'OOPS-A0001')
+        self.assertEqual(report['type'], None)
+        self.assertEqual(report['value'], None)
+        self.assertEqual(report['time'], None)
+        self.assertEqual(report['topic'], None)
+        self.assertEqual(report['tb_text'], '')
+        self.assertEqual(report['username'], None)
+        self.assertEqual(report['url'], None)
+        self.assertEqual(report['duration'], -1)
+        self.assertEqual(len(report['req_vars']), 0)
+        self.assertEqual(len(report['timeline']), 0)
+        self.assertEqual(report['branch_nick'], None)
+        self.assertEqual(report['revno'], None)
+
+
+class TestSerializing(testtools.TestCase):
+
+    def test_dumps(self):
+        report = {
+            'id': 'OOPS-A0001',
+            'type': 'NotFound',
+            'value': 'error message',
+            'time': datetime.datetime(2005, 04, 01, 00, 00, 00, tzinfo=utc),
+            'topic': 'IFoo:+foo-template',
+            'tb_text': 'traceback-text',
+            'username': 'Sample User',
+            'url': 'http://localhost:9000/foo',
+            'duration': 42,
+            'req_vars': [('HTTP_USER_AGENT', 'Mozilla/5.0'),
+                    ('HTTP_REFERER', 'http://localhost:9000/'),
+                    ('name=foo', 'hello\nworld')],
+            'timeline': [(1, 5, 'store_a', 'SELECT 1'),
+                    (5, 10, 'store_b', 'SELECT\n2')],
+            'informational': False,
+            'branch_nick': 'mybranch',
+            'revno': '45',
+            }
+        self.assertEqual(dumps(report), bson.dumps(report))
+
+    def test_minimal_oops(self):
+        # An oops with just an id, though arguably crazy, is written
+        # sensibly.
+        report = {'id': 'OOPS-1234'}
+        self.assertEqual(dumps(report), bson.dumps(report))
+
+    def test_bad_strings(self):
+        report = {'id': u'\xeafoo'}
+        self.assertEqual(dumps(report), bson.dumps(report))

=== modified file 'oops_datedir_repo/tests/test_serializer_rfc822.py'
--- oops_datedir_repo/tests/test_serializer_rfc822.py	2011-09-18 22:44:43 +0000
+++ oops_datedir_repo/tests/test_serializer_rfc822.py	2011-10-04 05:12:28 +0000
@@ -66,18 +66,14 @@
         self.assertEqual(report['url'], 'http://localhost:9000/foo')
         self.assertEqual(report['duration'], 42)
         self.assertEqual(len(report['req_vars']), 3)
-        self.assertEqual(report['req_vars'][0], ('HTTP_USER_AGENT',
-                                             'Mozilla/5.0'))
-        self.assertEqual(report['req_vars'][1], ('HTTP_REFERER',
-                                             'http://localhost:9000/'))
-        self.assertEqual(report['req_vars'][2], ('name=foo', 'hello\nworld'))
+        self.assertEqual(report['req_vars'][0],
+            ['HTTP_USER_AGENT', 'Mozilla/5.0'])
+        self.assertEqual(report['req_vars'][1],
+            ['HTTP_REFERER', 'http://localhost:9000/'])
+        self.assertEqual(report['req_vars'][2], ['name=foo', 'hello\nworld'])
         self.assertEqual(len(report['timeline']), 2)
-        self.assertEqual(
-            report['timeline'][0],
-            (1, 5, 'store_a', 'SELECT 1'))
-        self.assertEqual(
-            report['timeline'][1],
-            (5, 10, 'store_b', 'SELECT 2'))
+        self.assertEqual(report['timeline'][0], [1, 5, 'store_a', 'SELECT 1'])
+        self.assertEqual(report['timeline'][1], [5, 10, 'store_b', 'SELECT 2'])
 
     def test_read_blankline_req_vars(self):
         """Test ErrorReport.read() for old logs with a blankline between
@@ -104,12 +100,17 @@
                 foo/bar"""))
         report = read(fp)
         self.assertEqual(report['id'], 'OOPS-A0001')
-        self.assertEqual(report['req_vars'][0], ('HTTP_USER_AGENT',
-                                             'Mozilla/5.0'))
-        self.assertEqual(report['req_vars'][1], ('HTTP_REFERER',
-                                             'http://localhost:9000/'))
-        self.assertEqual(report['req_vars'][2], ('name=foo', 'hello\nworld'))
+        self.assertEqual(len(report['req_vars']), 3)
+        self.assertEqual(report['req_vars'][0],
+            ['HTTP_USER_AGENT', 'Mozilla/5.0'])
+        self.assertEqual(report['req_vars'][1],
+            ['HTTP_REFERER', 'http://localhost:9000/'])
+        self.assertEqual(report['req_vars'][2], ['name=foo', 'hello\nworld'])
         self.assertEqual(len(report['timeline']), 2)
+        self.assertEqual(
+            report['timeline'][0],
+            [1, 5, 'store_a', 'SELECT 1 = 2'])
+        self.assertEqual(report['timeline'][1], [5, 10, 'store_b', 'SELECT 2'])
         self.assertEqual(report['tb_text'], 'traceback-text\n    foo/bar')
 
     def test_read_no_store_id(self):
@@ -144,14 +145,14 @@
         self.assertEqual(report['url'], 'http://localhost:9000/foo')
         self.assertEqual(report['duration'], 42)
         self.assertEqual(len(report['req_vars']), 3)
-        self.assertEqual(report['req_vars'][0], ('HTTP_USER_AGENT',
-                                             'Mozilla/5.0'))
-        self.assertEqual(report['req_vars'][1], ('HTTP_REFERER',
-                                             'http://localhost:9000/'))
-        self.assertEqual(report['req_vars'][2], ('name=foo', 'hello\nworld'))
+        self.assertEqual(report['req_vars'][0],
+            ['HTTP_USER_AGENT', 'Mozilla/5.0'])
+        self.assertEqual(report['req_vars'][1],
+            ['HTTP_REFERER', 'http://localhost:9000/'])
+        self.assertEqual(report['req_vars'][2], ['name=foo', 'hello\nworld'])
         self.assertEqual(len(report['timeline']), 2)
-        self.assertEqual(report['timeline'][0], (1, 5, None, 'SELECT 1'))
-        self.assertEqual(report['timeline'][1], (5, 10, None, 'SELECT 2'))
+        self.assertEqual(report['timeline'][0], [1, 5, None, 'SELECT 1'])
+        self.assertEqual(report['timeline'][1], [5, 10, None, 'SELECT 2'])
 
     def test_read_branch_nick_revno(self):
         """Test ErrorReport.read()."""

=== modified file 'setup.py'
--- setup.py	2011-09-18 22:44:43 +0000
+++ setup.py	2011-10-04 05:12:28 +0000
@@ -22,7 +22,7 @@
 description = file(os.path.join(os.path.dirname(__file__), 'README'), 'rb').read()
 
 setup(name="oops_datedir_repo",
-      version="0.0.6",
+      version="0.0.7",
       description="OOPS disk serialisation and repository management.",
       long_description=description,
       maintainer="Launchpad Developers",
@@ -38,6 +38,7 @@
           'Programming Language :: Python',
           ],
       install_requires = [
+          'bson',
           'iso8601',
           'oops',
           'pytz',

=== modified file 'versions.cfg'
--- versions.cfg	2011-08-15 05:30:39 +0000
+++ versions.cfg	2011-10-04 05:12:28 +0000
@@ -2,6 +2,7 @@
 versions = versions
 
 [versions]
+bson = 0.3.2
 fixtures = 0.3.6
 iso8601 = 0.1.4
 pytz = 2010o