← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/python-oops/extraction into lp:python-oops

 

Robert Collins has proposed merging lp:~lifeless/python-oops/extraction into lp:python-oops.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lifeless/python-oops/extraction/+merge/71510

Rearranging how the oops code is organised - stuff I had extracted so far is really less core, so I've moved it to lp:python-oops-datedir-repo; this merge:
 - bumps to v4
 - deletes the moved stuff
 - adds the actual core - the Config object.
-- 
https://code.launchpad.net/~lifeless/python-oops/extraction/+merge/71510
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/python-oops/extraction into lp:python-oops.
=== modified file 'README'
--- README	2011-08-10 06:08:53 +0000
+++ README	2011-08-15 06:49:23 +0000
@@ -47,6 +47,59 @@
 of extensability and will ignore additional keys, and/or raise an error on keys
 they expect but which contain unexpected data.
 
+Typical usage:
+
+* When initializing your script/app/server, create a Config object::
+
+  >>> from oops import Config
+  >>> factory = Config()
+
+* New report will be based on the template::
+
+  >>> factory.template
+  {}
+
+* You can edit the template report::
+
+  >>> factory.template['branch_nick'] = 'mybranch'
+  >>> factory.template['appname'] = 'demo'
+
+* You can supply a callback (for instance, to capture your process memory usage
+when the oops is created)::
+
+  >>> mycallback = lambda report: None
+  >>> factory.on_create.append(mycallback)
+
+* Later on, when something has gone wrong and you want to create an OOPS
+report::
+
+  >>> report = factory.create()
+  >>> report
+  {'appname': 'demo', 'branch_nick': 'mybranch'}
+
+* And then send it off for storage::
+
+  >>> factory.publish(report)
+  []
+
+* Note that publish returns a list - each item in the list is the id allocated
+by the particular repository that recieved the report. (Id allocation is up
+to the repository). The last repository that received the report will have
+record its allocated id in the report for convenience.
+
+  >>> 'id' in report
+  False
+  >>> def demo_publish(report):
+  ...    return 'id 1'
+  >>> factory.publishers.append(demo_publish)
+  >>> factory.publish(report)
+  ['id 1']
+  >>> report['id']
+  'id 1'
+
+* The oops_datedir_repo package contains a local disk based repository which
+can be used as a publisher.
+
 More coming soon.
 
 Installation

=== modified file 'oops/__init__.py'
--- oops/__init__.py	2011-08-15 00:34:15 +0000
+++ oops/__init__.py	2011-08-15 06:49:23 +0000
@@ -25,8 +25,10 @@
 # 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, 3, 'beta', 0)
+__version__ = (0, 0, 4, 'beta', 0)
 
 __all__ = [
+    'Config'
     ]
 
+from oops.config import Config

=== added file 'oops/config.py'
--- oops/config.py	1970-01-01 00:00:00 +0000
+++ oops/config.py	2011-08-15 06:49:23 +0000
@@ -0,0 +1,122 @@
+# Copyright (c) 2010, 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).
+
+"""The primary interface for clients creating OOPS reports.
+
+Typical usage:
+
+* Configure the library::
+
+  >>> from oops import Config
+  >>> factory = Config()
+  >>> def demo_publish(report):
+  ...    return 'id 1'
+  >>> factory.publishers.append(demo_publish)
+
+* Create a report::
+
+  >>> report = factory.create()
+
+* And then send it off for storage::
+
+  >>> factory.publish(report)
+  ['id 1']
+  >>> report
+  {'id': 'id 1'}
+
+* See the Config object pydoc for more information.
+
+OOPS reports a bson serializable dicts (providing a capable lowest common
+denominator - its safe to put binary data in them).
+
+Some well known keys used by Launchpad in its OOPS reports::
+
+* 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.
+* pageid: 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.
+* Informational: A flag, True if the error wasn't fatal- if it was
+  'informational'.
+"""
+
+
+__all__ = [
+    'Config',
+    ]
+
+__metaclass__ = type
+
+
+class Config:
+    """The configuration for the OOPS system.
+    
+    :ivar on_create: A list of callables to call when making a new report. Each
+        will be called in series with the new report, and its return value will
+        be ignored.
+    :ivar publishers: A list of callables to call when publishing a report.
+        Each will be called in series with the report to publish. Their return
+        value will be assigned to the reports 'id' key : if a publisher
+        allocates a different id than a prior publisher, only the last
+        publisher in the list will have its id present in the report at the
+        end. See the publish() method for more information.
+    """
+
+    def __init__(self):
+        self.on_create = []
+        self.template = {}
+        self.publishers = []
+
+    def create(self):
+        """Create an OOPS.
+
+        The current template used copied to make the new report, and it is
+        passed to all the on_create callbacks for population.
+
+        :return: A fresh OOPS.
+        """
+        result = dict(self.template)
+        [callback(result) for callback in self.on_create]
+        return result
+
+    def publish(self, report):
+        """Publish a report.
+
+        Each publisher is passed the report to publish. Publishers should
+        return the id they allocated-or-used for the report, which gets
+        automatically put into the report for them. All of the ids are also
+        returned to the caller, allowing them to handle the case when multiple
+        publishers allocated different ids. The id from the last publisher is
+        left in the report's id key as a convenience for the common case when
+        only one publisher is present.
+
+        :return: A list of the allocated ids.
+        """
+        result = []
+        for publisher in self.publishers:
+            id = publisher(report)
+            report['id'] = id
+            result.append(id)
+        return result

=== removed file 'oops/serializer_rfc822.py'
--- oops/serializer_rfc822.py	2011-08-15 00:34:15 +0000
+++ oops/serializer_rfc822.py	1970-01-01 00:00:00 +0000
@@ -1,198 +0,0 @@
-# Copyright (c) 2010, 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 an rfc822 formatted message.
-
-This style of OOPS format is very web server specific, not extensible - it
-should be considered deprecated.
-
-The reports this serializer handles always have the following 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.
-* pageid: 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.
-* Informational: A flag, True if the error wasn't fatal- if it was
-  'informational'.
-"""
-
-
-__all__ = [
-    'read',
-    'write',
-    ]
-
-__metaclass__ = type
-
-import datetime
-import logging
-import rfc822
-import re
-import urllib
-
-import iso8601
-
-
-def read(fp):
-    """Deserialize an OOPS from an RFC822 format message."""
-    msg = rfc822.Message(fp)
-    id = msg.getheader('oops-id')
-    exc_type = msg.getheader('exception-type')
-    exc_value = msg.getheader('exception-value')
-    datestr = msg.getheader('date')
-    if datestr is not None:
-        date =iso8601.parse_date(msg.getheader('date'))
-    else:
-        date = None
-    pageid = msg.getheader('page-id')
-    username = msg.getheader('user')
-    url = msg.getheader('url')
-    duration = int(float(msg.getheader('duration', '-1')))
-    informational = msg.getheader('informational')
-    branch_nick = msg.getheader('branch')
-    revno = msg.getheader('revision')
-
-    # Explicitly use an iterator so we can process the file
-    # sequentially. In most instances the iterator will actually
-    # be the file object passed in because file objects should
-    # support iteration.
-    lines = iter(msg.fp)
-
-    # Request variables until the first blank line.
-    req_vars = []
-    for line in lines:
-        line = line.strip()
-        if line == '':
-            break
-        key, value = line.split('=', 1)
-        req_vars.append((urllib.unquote(key), urllib.unquote(value)))
-
-    # Statements until the next blank line.
-    statements = []
-    for line in lines:
-        line = line.strip()
-        if line == '':
-            break
-        match = re.match(
-            r'^(\d+)-(\d+)(?:@([\w-]+))?\s+(.*)', line)
-        assert match is not None, (
-            "Unable to interpret oops line: %s" % line)
-        start, end, db_id, statement = match.groups()
-        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))
-
-    # The rest is traceback.
-    tb_text = ''.join(lines)
-
-    return dict(id=id, type=exc_type, value=exc_value, time=date,
-            pageid=pageid, tb_text=tb_text, username=username, url=url,
-            duration=duration, req_vars=req_vars, db_statements=statements,
-            informational=informational, branch_nick=branch_nick, revno=revno)
-
-
-def _normalise_whitespace(s):
-    """Normalise the whitespace in a string to spaces"""
-    if s is None:
-        return None # (used by the cast to %s to get 'None')
-    return ' '.join(s.split())
-
-
-def _safestr(obj):
-    if isinstance(obj, unicode):
-        return obj.replace('\\', '\\\\').encode('ASCII',
-                                                'backslashreplace')
-    # A call to str(obj) could raise anything at all.
-    # We'll ignore these errors, and print something
-    # useful instead, but also log the error.
-    # We disable the pylint warning for the blank except.
-    try:
-        value = str(obj)
-    except:
-        logging.getLogger('oops.serializer_rfc822').exception(
-            'Error while getting a str '
-            'representation of an object')
-        value = '<unprintable %s object>' % (
-            str(type(obj).__name__))
-    # Some str() calls return unicode objects.
-    if isinstance(value, unicode):
-        return _safestr(value)
-    # encode non-ASCII characters
-    value = value.replace('\\', '\\\\')
-    value = re.sub(r'[\x80-\xff]',
-                   lambda match: '\\x%02x' % ord(match.group(0)), value)
-    return value
-
-
-def to_chunks(report):
-    """Returns a list of bytestrings making up the serialized oops."""
-    chunks = []
-    chunks.append('Oops-Id: %s\n' % _normalise_whitespace(report['id']))
-    if 'type' in report:
-        chunks.append(
-            'Exception-Type: %s\n' % _normalise_whitespace(report['type']))
-    if 'value' in report:
-        chunks.append(
-            'Exception-Value: %s\n' % _normalise_whitespace(report['value']))
-    if 'time' in report:
-        chunks.append('Date: %s\n' % report['time'].isoformat())
-    if 'pageid' in report:
-        chunks.append(
-                'Page-Id: %s\n' % _normalise_whitespace(report['pageid']))
-    if 'branch_nick' in report:
-        chunks.append('Branch: %s\n' % _safestr(report['branch_nick']))
-    if 'revno' in report:
-        chunks.append('Revision: %s\n' % report['revno'])
-    if 'username' in report:
-        chunks.append(
-                'User: %s\n' % _normalise_whitespace(report['username']))
-    if 'url' in report:
-        chunks.append('URL: %s\n' % _normalise_whitespace(report['url']))
-    if 'duration' in report:
-        chunks.append('Duration: %s\n' % report['duration'])
-    if 'informational' in report:
-        chunks.append('Informational: %s\n' % report['informational'])
-    chunks.append('\n')
-    safe_chars = ';/\\?:@&+$, ()*!'
-    if 'req_vars' in report:
-        for key, value in report['req_vars']:
-            chunks.append('%s=%s\n' % (urllib.quote(key, safe_chars),
-                                  urllib.quote(value, safe_chars)))
-        chunks.append('\n')
-    if 'db_statements' in report:
-        for (start, end, database_id, statement) in report['db_statements']:
-            chunks.append('%05d-%05d@%s %s\n' % (
-                start, end, database_id, _normalise_whitespace(statement)))
-        chunks.append('\n')
-    if 'tb_text' in report:
-        chunks.append(report['tb_text'])
-    return chunks
-
-
-def write(report, output):
-    """Write a report to a file."""
-    output.writelines(to_chunks(report))

=== modified file 'oops/tests/__init__.py'
--- oops/tests/__init__.py	2011-08-10 06:08:53 +0000
+++ oops/tests/__init__.py	2011-08-15 06:49:23 +0000
@@ -21,8 +21,7 @@
 
 def test_suite():
     test_mod_names = [
-        'uniquefileallocator',
-        'serializer_rfc822',
+        'config',
         ]
     return TestLoader().loadTestsFromNames(
         ['oops.tests.test_' + name for name in test_mod_names])

=== added file 'oops/tests/test_config.py'
--- oops/tests/test_config.py	1970-01-01 00:00:00 +0000
+++ oops/tests/test_config.py	2011-08-15 06:49:23 +0000
@@ -0,0 +1,68 @@
+# Copyright (c) 2010, 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 legacy rfc822 based [de]serializer."""
+
+__metaclass__ = type
+
+from functools import partial
+
+import testtools
+from testtools.matchers import Is
+
+from oops.config import Config
+
+
+class TestConfig(testtools.TestCase):
+
+    def test_init(self):
+        config = Config()
+        self.assertEqual([], config.on_create)
+        self.assertEqual([], config.publishers)
+        self.assertEqual({}, config.template)
+
+    def test_on_create_called(self):
+        calls = []
+        def capture(id, report):
+            calls.append((id, report))
+        config = Config()
+        config.on_create.append(partial(capture, '1'))
+        config.on_create.append(partial(capture, '2'))
+        report = config.create()
+        self.assertThat(report, Is(calls[0][1]))
+        self.assertThat(report, Is(calls[1][1]))
+        self.assertEqual([('1', {}), ('2', {})], calls)
+
+    def test_create_template(self):
+        config = Config()
+        config.template['base'] = True
+        report = config.create()
+        self.assertEqual({'base': True}, report)
+
+    def test_publish_calls_publishers(self):
+        calls = []
+        def pub_1(report):
+            return '1'
+        def pub_2(report):
+            self.assertEqual('1', report['id'])
+            return '2'
+        config = Config()
+        config.publishers.append(pub_1)
+        config.publishers.append(pub_2)
+        report = {}
+        self.assertEqual(['1', '2'], config.publish(report))
+        self.assertEqual({'id': '2'}, report)
+

=== removed file 'oops/tests/test_serializer_rfc822.py'
--- oops/tests/test_serializer_rfc822.py	2011-08-15 00:34:15 +0000
+++ oops/tests/test_serializer_rfc822.py	1970-01-01 00:00:00 +0000
@@ -1,271 +0,0 @@
-# Copyright (c) 2010, 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 legacy rfc822 based [de]serializer."""
-
-__metaclass__ = type
-
-import datetime
-import StringIO
-from textwrap import dedent
-
-from pytz import utc
-import testtools
-
-from oops.serializer_rfc822 import (
-    read,
-    to_chunks,
-    write,
-    )
-
-
-class TestParsing(testtools.TestCase):
-
-    def test_read(self):
-        """Test ErrorReport.read()."""
-        fp = StringIO.StringIO(dedent("""\
-            Oops-Id: OOPS-A0001
-            Exception-Type: NotFound
-            Exception-Value: error message
-            Date: 2005-04-01T00:00:00+00:00
-            Page-Id: IFoo:+foo-template
-            User: Sample User
-            URL: http://localhost:9000/foo
-            Duration: 42
-
-            HTTP_USER_AGENT=Mozilla/5.0
-            HTTP_REFERER=http://localhost:9000/
-            name%3Dfoo=hello%0Aworld
-
-            00001-00005@store_a SELECT 1
-            00005-00010@store_b SELECT 2
-
-            traceback-text"""))
-        report = read(fp)
-        self.assertEqual(report['id'], 'OOPS-A0001')
-        self.assertEqual(report['type'], 'NotFound')
-        self.assertEqual(report['value'], 'error message')
-        self.assertEqual(
-                report['time'], datetime.datetime(2005, 4, 1, tzinfo=utc))
-        self.assertEqual(report['pageid'], 'IFoo:+foo-template')
-        self.assertEqual(report['tb_text'], 'traceback-text')
-        self.assertEqual(report['username'], 'Sample User')
-        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(len(report['db_statements']), 2)
-        self.assertEqual(
-            report['db_statements'][0],
-            (1, 5, 'store_a', 'SELECT 1'))
-        self.assertEqual(
-            report['db_statements'][1],
-            (5, 10, 'store_b', 'SELECT 2'))
-
-    def test_read_no_store_id(self):
-        """Test ErrorReport.read() for old logs with no store_id."""
-        fp = StringIO.StringIO(dedent("""\
-            Oops-Id: OOPS-A0001
-            Exception-Type: NotFound
-            Exception-Value: error message
-            Date: 2005-04-01T00:00:00+00:00
-            Page-Id: IFoo:+foo-template
-            User: Sample User
-            URL: http://localhost:9000/foo
-            Duration: 42
-
-            HTTP_USER_AGENT=Mozilla/5.0
-            HTTP_REFERER=http://localhost:9000/
-            name%3Dfoo=hello%0Aworld
-
-            00001-00005 SELECT 1
-            00005-00010 SELECT 2
-
-            traceback-text"""))
-        report = read(fp)
-        self.assertEqual(report['id'], 'OOPS-A0001')
-        self.assertEqual(report['type'], 'NotFound')
-        self.assertEqual(report['value'], 'error message')
-        self.assertEqual(
-                report['time'], datetime.datetime(2005, 4, 1, tzinfo=utc))
-        self.assertEqual(report['pageid'], 'IFoo:+foo-template')
-        self.assertEqual(report['tb_text'], 'traceback-text')
-        self.assertEqual(report['username'], 'Sample User')
-        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(len(report['db_statements']), 2)
-        self.assertEqual(report['db_statements'][0], (1, 5, None, 'SELECT 1'))
-        self.assertEqual(report['db_statements'][1], (5, 10, None, 'SELECT 2'))
-        self.assertFalse(report['informational'])
-
-    def test_read_branch_nick_revno(self):
-        """Test ErrorReport.read()."""
-        fp = StringIO.StringIO(dedent("""\
-            Oops-Id: OOPS-A0001
-            Exception-Type: NotFound
-            Exception-Value: error message
-            Date: 2005-04-01T00:00:00+00:00
-            Page-Id: IFoo:+foo-template
-            User: Sample User
-            URL: http://localhost:9000/foo
-            Duration: 42
-            Branch: mybranch
-            Revision: 45
-
-            HTTP_USER_AGENT=Mozilla/5.0
-            HTTP_REFERER=http://localhost:9000/
-            name%3Dfoo=hello%0Aworld
-
-            00001-00005@store_a SELECT 1
-            00005-00010@store_b SELECT 2
-
-            traceback-text"""))
-        report = read(fp)
-        self.assertEqual(report['branch_nick'], 'mybranch')
-        self.assertEqual(report['revno'], '45')
-
-    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.
-        fp = StringIO.StringIO(dedent("""\
-            Oops-Id: OOPS-A0001
-            """))
-        report = read(fp)
-        self.assertEqual(report['id'], 'OOPS-A0001')
-        self.assertEqual(report['type'], None)
-        self.assertEqual(report['value'], None)
-        self.assertEqual(report['time'], None)
-        self.assertEqual(report['pageid'], 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['db_statements']), 0)
-        self.assertFalse(report['informational'])
-        self.assertEqual(report['branch_nick'], None)
-        self.assertEqual(report['revno'], None)
-
-
-class TestSerializing(testtools.TestCase):
-
-    def test_write_file(self):
-        output = StringIO.StringIO()
-        report = {
-            'id': 'OOPS-A0001',
-            'type': 'NotFound',
-            'value': 'error message',
-            'time': datetime.datetime(2005, 04, 01, 00, 00, 00, tzinfo=utc),
-            'pageid': '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')],
-            'db_statements': [(1, 5, 'store_a', 'SELECT 1'),
-                    (5, 10, 'store_b', 'SELECT\n2')],
-            'informational': False,
-            'branch_nick': 'mybranch',
-            'revno': '45',
-            }
-        write(report, output)
-        self.assertEqual(output.getvalue(), dedent("""\
-            Oops-Id: OOPS-A0001
-            Exception-Type: NotFound
-            Exception-Value: error message
-            Date: 2005-04-01T00:00:00+00:00
-            Page-Id: IFoo:+foo-template
-            Branch: mybranch
-            Revision: 45
-            User: Sample User
-            URL: http://localhost:9000/foo
-            Duration: 42
-            Informational: False
-
-            HTTP_USER_AGENT=Mozilla/5.0
-            HTTP_REFERER=http://localhost:9000/
-            name%3Dfoo=hello%0Aworld
-
-            00001-00005@store_a SELECT 1
-            00005-00010@store_b SELECT 2
-
-            traceback-text"""))
-
-    def test_to_chunks(self):
-        report = {
-            'id': 'OOPS-A0001',
-            'type': 'NotFound',
-            'value': 'error message',
-            'time': datetime.datetime(2005, 04, 01, 00, 00, 00, tzinfo=utc),
-            'pageid': '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')],
-            'db_statements': [(1, 5, 'store_a', 'SELECT 1'),
-                    (5, 10, 'store_b', 'SELECT\n2')],
-            'informational': False,
-            'branch_nick': 'mybranch',
-            'revno': '45',
-            }
-        self.assertEqual([
-            "Oops-Id: OOPS-A0001\n",
-            "Exception-Type: NotFound\n",
-            "Exception-Value: error message\n",
-            "Date: 2005-04-01T00:00:00+00:00\n",
-            "Page-Id: IFoo:+foo-template\n",
-            "Branch: mybranch\n",
-            "Revision: 45\n",
-            "User: Sample User\n",
-            "URL: http://localhost:9000/foo\n";,
-            "Duration: 42\n",
-            "Informational: False\n",
-            "\n",
-            "HTTP_USER_AGENT=Mozilla/5.0\n",
-            "HTTP_REFERER=http://localhost:9000/\n";,
-            "name%3Dfoo=hello%0Aworld\n",
-            "\n",
-            "00001-00005@store_a SELECT 1\n",
-            "00005-00010@store_b SELECT 2\n",
-            "\n",
-            "traceback-text",
-            ],
-            to_chunks(report))
-
-    def test_minimal_oops(self):
-        # An oops with just an id, though arguably crazy, is written
-        # sensibly.
-        report = {'id': 'OOPS-1234'}
-        self.assertEqual([
-            "Oops-Id: OOPS-1234\n",
-            "\n"
-            ], to_chunks(report))

=== removed file 'oops/tests/test_uniquefileallocator.py'
--- oops/tests/test_uniquefileallocator.py	2011-08-10 05:08:58 +0000
+++ oops/tests/test_uniquefileallocator.py	1970-01-01 00:00:00 +0000
@@ -1,161 +0,0 @@
-# Copyright (c) 2010, 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 unique file naming facility."""
-
-__metaclass__ = type
-
-import datetime
-import os
-import stat
-
-from fixtures import TempDir
-import pytz
-import testtools
-
-from oops.uniquefileallocator import UniqueFileAllocator
-
-
-UTC = pytz.timezone('UTC')
-
-
-class TestUniqueFileAllocator(testtools.TestCase):
-
-    def setUp(self):
-        super(TestUniqueFileAllocator, self).setUp()
-        tempdir = self.useFixture(TempDir())
-        self._tempdir = tempdir.path
-
-    def test_setToken(self):
-        namer = UniqueFileAllocator("/any-old/path/", 'OOPS', 'T')
-        self.assertEqual('T', namer.get_log_infix())
-
-        # Some scripts will append a string token to the prefix.
-        namer.setToken('CW')
-        self.assertEqual('TCW', namer.get_log_infix())
-
-        # Some scripts run multiple processes and append a string number
-        # to the prefix.
-        namer.setToken('1')
-        self.assertEqual('T1', namer.get_log_infix())
-
-    def assertUniqueFileAllocator(self, namer, now, expected_id,
-        expected_last_id, expected_suffix, expected_lastdir):
-        logid, filename = namer.newId(now)
-        self.assertEqual(logid, expected_id)
-        self.assertEqual(filename,
-            os.path.join(namer._output_root, expected_suffix))
-        self.assertEqual(namer._last_serial, expected_last_id)
-        self.assertEqual(namer._last_output_dir,
-            os.path.join(namer._output_root, expected_lastdir))
-
-    def test_newId(self):
-        # TODO: This should return an id, fileobj instead of a file name, to
-        # reduce races with threads that are slow to use what they asked for,
-        # when combined with configuration changes causing disk scans. That
-        # would also permit using a completely stubbed out file system,
-        # reducing IO in tests that use UniqueFileAllocator (such as all the
-        # pagetests in Launchpad. At that point an interface to obtain a
-        # factory of UniqueFileAllocator's would be useful to parameterise the
-        # entire test suite.
-        namer = UniqueFileAllocator(self._tempdir, 'OOPS', 'T')
-        # first name of the day
-        self.assertUniqueFileAllocator(namer,
-            datetime.datetime(2006, 04, 01, 00, 30, 00, tzinfo=UTC),
-            'OOPS-91T1', 1, '2006-04-01/01800.T1', '2006-04-01')
-        # second name of the day
-        self.assertUniqueFileAllocator(namer,
-            datetime.datetime(2006, 04, 01, 12, 00, 00, tzinfo=UTC),
-            'OOPS-91T2', 2, '2006-04-01/43200.T2', '2006-04-01')
-
-        # first name of the following day sets a new dir and the id starts
-        # over.
-        self.assertUniqueFileAllocator(namer,
-            datetime.datetime(2006, 04, 02, 00, 30, 00, tzinfo=UTC),
-            'OOPS-92T1', 1, '2006-04-02/01800.T1', '2006-04-02')
-
-        # Setting a token inserts the token into the filename.
-        namer.setToken('YYY')
-        logid, filename = namer.newId(
-            datetime.datetime(2006, 04, 02, 00, 30, 00, tzinfo=UTC))
-        self.assertEqual(logid, 'OOPS-92TYYY2')
-
-        # Setting a type controls the log id:
-        namer.setToken('')
-        namer._log_type = "PROFILE"
-        logid, filename = namer.newId(
-            datetime.datetime(2006, 04, 02, 00, 30, 00, tzinfo=UTC))
-        self.assertEqual(logid, 'PROFILE-92T3')
-
-        # Native timestamps are not permitted - UTC only.
-        now = datetime.datetime(2006, 04, 02, 00, 30, 00)
-        self.assertRaises(ValueError, namer.newId, now)
-
-    def test_changeErrorDir(self):
-        """Test changing the log output dir."""
-        namer = UniqueFileAllocator(self._tempdir, 'OOPS', 'T')
-
-        # First an id in the original error directory.
-        self.assertUniqueFileAllocator(namer,
-            datetime.datetime(2006, 04, 01, 00, 30, 00, tzinfo=UTC),
-            'OOPS-91T1', 1, '2006-04-01/01800.T1', '2006-04-01')
-
-        # UniqueFileAllocator uses the _output_root attribute to get the
-        # current output directory.
-        new_output_dir = self.useFixture(TempDir()).path
-        namer._output_root = new_output_dir
-
-        # Now an id on the same day, in the new directory.
-        now = datetime.datetime(2006, 04, 01, 12, 00, 00, tzinfo=UTC)
-        log_id, filename = namer.newId(now)
-
-        # Since it's a new directory, with no previous logs, the id is 1
-        # again, rather than 2.
-        self.assertEqual(log_id, 'OOPS-91T1')
-        self.assertEqual(namer._last_serial, 1)
-        self.assertEqual(namer._last_output_dir,
-            os.path.join(new_output_dir, '2006-04-01'))
-
-    def test_findHighestSerial(self):
-        namer = UniqueFileAllocator(self._tempdir, "OOPS", "T")
-        # Creates the dir using now as the timestamp.
-        output_dir = namer.output_dir()
-        # write some files, in non-serial order.
-        open(os.path.join(output_dir, '12343.T1'), 'w').close()
-        open(os.path.join(output_dir, '12342.T2'), 'w').close()
-        open(os.path.join(output_dir, '12345.T3'), 'w').close()
-        open(os.path.join(output_dir, '1234567.T0010'), 'w').close()
-        open(os.path.join(output_dir, '12346.A42'), 'w').close()
-        open(os.path.join(output_dir, '12346.B100'), 'w').close()
-        # The namer should figure out the right highest serial.
-        self.assertEqual(namer._findHighestSerial(output_dir), 10)
-
-    def test_output_dir_permission(self):
-        # Set up default dir creation mode to rwx------.
-        umask_permission = stat.S_IRWXG | stat.S_IRWXO
-        old_umask = os.umask(umask_permission)
-        namer = UniqueFileAllocator(self._tempdir, "OOPS", "T")
-        output_dir = namer.output_dir()
-        st = os.stat(output_dir)
-        # Permission we want here is: rwxr-xr-x
-        wanted_permission = (
-            stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH |
-            stat.S_IXOTH)
-        # Get only the permission bits for this directory.
-        dir_permission = stat.S_IMODE(st.st_mode)
-        self.assertEqual(dir_permission, wanted_permission)
-        # Restore the umask to the original value.
-        ignored = os.umask(old_umask)

=== removed file 'oops/uniquefileallocator.py'
--- oops/uniquefileallocator.py	2011-08-10 05:08:58 +0000
+++ oops/uniquefileallocator.py	1970-01-01 00:00:00 +0000
@@ -1,213 +0,0 @@
-# Copyright (c) 2010, 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).
-
-
-"""Create uniquely named log files on disk."""
-
-
-__all__ = ['UniqueFileAllocator']
-
-__metaclass__ = type
-
-
-import datetime
-import errno
-import os.path
-import stat
-import threading
-
-import pytz
-
-
-UTC = pytz.utc
-
-# the section of the ID before the instance identifier is the
-# days since the epoch, which is defined as the start of 2006.
-epoch = datetime.datetime(2006, 01, 01, 00, 00, 00, tzinfo=UTC)
-
-
-class UniqueFileAllocator:
-    """Assign unique file names to logs being written from an app/script.
-
-    UniqueFileAllocator causes logs written from one process to be uniquely
-    named. It is not safe for use in multiple processes with the same output
-    root - each process must have a unique output root.
-    """
-
-    def __init__(self, output_root, log_type, log_subtype):
-        """Create a UniqueFileAllocator.
-
-        :param output_root: The root directory that logs should be placed in.
-        :param log_type: A string to use as a prefix in the ID assigned to new
-            logs. For instance, "OOPS".
-        :param log_subtype: A string to insert in the generate log filenames
-            between the day number and the serial. For instance "T" for
-            "Testing".
-        """
-        self._lock = threading.Lock()
-        self._output_root = output_root
-        self._last_serial = 0
-        self._last_output_dir = None
-        self._log_type = log_type
-        self._log_subtype = log_subtype
-        self._log_token = ""
-
-    def _findHighestSerialFilename(self, directory=None, time=None):
-        """Find details of the last log present in the given directory.
-
-        This function only considers logs with the currently
-        configured log_subtype.
-
-        One of directory, time must be supplied.
-
-        :param directory: Look in this directory.
-        :param time: Look in the directory that a log written at this time
-            would have been written to. If supplied, supercedes directory.
-        :return: a tuple (log_serial, log_filename), which will be (0,
-            None) if no logs are found. log_filename is a usable path, not
-            simply the basename.
-        """
-        if directory is None:
-            directory = self.output_dir(time)
-        prefix = self.get_log_infix()
-        lastid = 0
-        lastfilename = None
-        for filename in os.listdir(directory):
-            logid = filename.rsplit('.', 1)[1]
-            if not logid.startswith(prefix):
-                continue
-            logid = logid[len(prefix):]
-            if logid.isdigit() and (lastid is None or int(logid) > lastid):
-                lastid = int(logid)
-                lastfilename = filename
-        if lastfilename is not None:
-            lastfilename = os.path.join(directory, lastfilename)
-        return lastid, lastfilename
-
-    def _findHighestSerial(self, directory):
-        """Find the last serial actually applied to disk in directory.
-
-        The purpose of this function is to not repeat sequence numbers
-        if the logging application is restarted.
-
-        This method is not thread safe, and only intended to be called
-        from the constructor (but it is called from other places in
-        integration tests).
-        """
-        return self._findHighestSerialFilename(directory)[0]
-
-    def getFilename(self, log_serial, time):
-        """Get the filename for a given log serial and time."""
-        log_subtype = self.get_log_infix()
-        # TODO: Calling output_dir causes a global lock to be taken and a
-        # directory scan, which is bad for performance. It would be better
-        # to have a split out 'directory name for time' function which the
-        # 'want to use this directory now' function can call.
-        output_dir = self.output_dir(time)
-        second_in_day = time.hour * 3600 + time.minute * 60 + time.second
-        return os.path.join(
-            output_dir, '%05d.%s%s' % (
-            second_in_day, log_subtype, log_serial))
-
-    def get_log_infix(self):
-        """Return the current log infix to use in ids and file names."""
-        return self._log_subtype + self._log_token
-
-    def newId(self, now=None):
-        """Returns an (id, filename) pair for use by the caller.
-
-        The ID is composed of a short string to identify the Launchpad
-        instance followed by an ID that is unique for the day.
-
-        The filename is composed of the zero padded second in the day
-        followed by the ID.  This ensures that reports are in date order when
-        sorted lexically.
-        """
-        if now is not None:
-            now = now.astimezone(UTC)
-        else:
-            now = datetime.datetime.now(UTC)
-        # We look up the error directory before allocating a new ID,
-        # because if the day has changed, errordir() will reset the ID
-        # counter to zero.
-        self.output_dir(now)
-        self._lock.acquire()
-        try:
-            self._last_serial += 1
-            newid = self._last_serial
-        finally:
-            self._lock.release()
-        subtype = self.get_log_infix()
-        day_number = (now - epoch).days + 1
-        log_id = '%s-%d%s%d' % (self._log_type, day_number, subtype, newid)
-        filename = self.getFilename(newid, now)
-        return log_id, filename
-
-    def output_dir(self, now=None):
-        """Find or make the directory to allocate log names in.
-
-        Log names are assigned within subdirectories containing the date the
-        assignment happened.
-        """
-        if now is not None:
-            now = now.astimezone(UTC)
-        else:
-            now = datetime.datetime.now(UTC)
-        date = now.strftime('%Y-%m-%d')
-        result = os.path.join(self._output_root, date)
-        if result != self._last_output_dir:
-            self._lock.acquire()
-            try:
-                self._last_output_dir = result
-                # make sure the directory exists
-                try:
-                    os.makedirs(result)
-                except OSError, e:
-                    if e.errno != errno.EEXIST:
-                        raise
-                # Make sure the directory permission is set to: rwxr-xr-x
-                permission = (
-                    stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP |
-                    stat.S_IROTH | stat.S_IXOTH)
-                os.chmod(result, permission)
-                # TODO: Note that only one process can do this safely: its not
-                # cross-process safe, and also not entirely threadsafe:
-                # another # thread that has a new log and hasn't written it
-                # could then use that serial number. We should either make it
-                # really safe, or remove the contention entirely and log
-                # uniquely per thread of execution.
-                self._last_serial = self._findHighestSerial(result)
-            finally:
-                self._lock.release()
-        return result
-
-    def listRecentReportFiles(self):
-        now = datetime.datetime.now(UTC)
-        yesterday = now - datetime.timedelta(days=1)
-        directories = [self.output_dir(now), self.output_dir(yesterday)]
-        for directory in directories:
-            report_names = os.listdir(directory)
-            for name in sorted(report_names, reverse=True):
-                yield directory, name
-
-    def setToken(self, token):
-        """Append a string to the log subtype in filenames and log ids.
-
-        :param token: a string to append..
-            Scripts that run multiple processes can use this to create a
-            unique identifier for each process.
-        """
-        self._log_token = token

=== modified file 'setup.py'
--- setup.py	2011-08-15 00:34:15 +0000
+++ setup.py	2011-08-15 06:49:23 +0000
@@ -22,7 +22,7 @@
 description = file(os.path.join(os.path.dirname(__file__), 'README'), 'rb').read()
 
 setup(name="oops",
-      version="0.0.3",
+      version="0.0.4",
       description="OOPS report model and default allocation/[de]serialization.",
       long_description=description,
       maintainer="Launchpad Developers",