← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

Extract and refactor with an eye to dead-simple the launchpad-loggerhead wsgi middleware. The tests in LP provided the primary guidance for things that could go wrong.
-- 
https://code.launchpad.net/~lifeless/python-oops-wsgi/extraction/+merge/71839
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/python-oops-wsgi/extraction into lp:python-oops-wsgi.
=== modified file 'README'
--- README	2011-08-17 06:36:18 +0000
+++ README	2011-08-17 09:53:30 +0000
@@ -27,6 +27,8 @@
 
 * oops (http://pypi.python.org/pypi/oops)
 
+* paste
+
 Testing Dependencies
 ====================
 
@@ -70,11 +72,16 @@
 client and the exception will propogate up the wsgi app stack.
 
 You can customise the error page if you supply a helper that accepts (environ,
-oopsid) and returns HTML to be sent to the client.
-
-   >>> def myerror_html(environ, oops_id):
-   ...    return '<html><body><h1>OOPS! %s</h1></body></html>'
-   >>> app = oops_wsgi.make_app(app, config, myerror_html)
+report) and returns HTML to be sent to the client.
+
+   >>> def myerror_html(environ, report):
+   ...    return '<html><body><h1>OOPS! %s</h1></body></html>' % report['id']
+   >>> app = oops_wsgi.make_app(app, config, error_render=myerror_html)
+
+Or you can supply a string template to be formatted with the report.
+
+   >>> json_template='{"oopsid" : "%(id)s"}'
+   >>> app = oops_wsgi.make_app(app, config, error_template=json_template)
 
 More coming soon.
 

=== modified file 'buildout.cfg'
--- buildout.cfg	2011-08-17 06:36:18 +0000
+++ buildout.cfg	2011-08-17 09:53:30 +0000
@@ -34,5 +34,6 @@
 eggs = oops-wsgi [test]
 include-site-packages = true
 allowed-eggs-from-site-packages = 
+    paste
     subunit
 interpreter = py

=== modified file 'oops_wsgi/__init__.py'
--- oops_wsgi/__init__.py	2011-08-17 06:36:18 +0000
+++ oops_wsgi/__init__.py	2011-08-17 09:53:30 +0000
@@ -49,11 +49,16 @@
 client and the exception will propogate up the wsgi app stack.
 
 You can customise the error page if you supply a helper that accepts (environ,
-oopsid) and returns HTML to be sent to the client.
-
-   >>> def myerror_html(environ, oops_id):
-   ...    return '<html><body><h1>OOPS! %s</h1></body></html>'
-   >>> app = oops_wsgi.make_app(app, config, myerror_html)
+report) and returns HTML to be sent to the client.
+
+   >>> def myerror_html(environ, report):
+   ...    return '<html><body><h1>OOPS! %s</h1></body></html>' % report['id']
+   >>> app = oops_wsgi.make_app(app, config, error_render=myerror_html)
+
+Or you can supply a string template to be formatted with the report.
+
+   >>> json_template='{"oopsid" : "%(id)s"}'
+   >>> app = oops_wsgi.make_app(app, config, error_template=json_template)
 """
 
 
@@ -74,3 +79,4 @@
     'make_app'
     ]
 
+from oops_wsgi.middleware import make_app

=== added file 'oops_wsgi/middleware.py'
--- oops_wsgi/middleware.py	1970-01-01 00:00:00 +0000
+++ oops_wsgi/middleware.py	2011-08-17 09:53:30 +0000
@@ -0,0 +1,118 @@
+# 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).
+
+"""WSGI middleware to integrate with an oops.Config."""
+
+__metaclass__ = type
+
+import socket
+import sys
+
+__all__ = [
+    'make_app',
+    ]
+
+
+default_error_template='''<html>
+<head><title>Oops! - %(id)s</title></head>
+<body>
+<h1>Oops!</h1>
+<p>Something broke while generating the page.
+Please try again in a few minutes, and if the problem persists file
+a bug or contact customer support. PLease quote OOPS-ID
+<strong>%(id)s</strong>
+</p></body></html>'''
+
+
+def make_app(app, config, template=default_error_template,
+        content_type='text/html', error_render=None):
+    """Construct a middleware around app that will forward errors via config.
+
+    Any errors encountered by the app will be forwarded to config and an error
+    page shown.
+
+    If the body of a reply has already started the error will be forwarded to
+    config and also re-raised.
+
+    If there are no publishers, or an error is filtered, the error will be
+    re-raised rather than an error page shown. This permits containing
+    middleware to show custom errors (for 404's, for instance), perhaps even
+    for just some occurences of the issue.
+
+    :param app: A WSGI app.
+    :param config: An oops.Config.
+    :param template: Optional string template to use when reporting the oops to
+        the client. If not supplied a default template is used (unless an
+        error_render function has been supplied).
+    :param content_type: The content type for error pages. Defaults to
+        text/html.
+    :param error_render: Optional custom renderer for presenting error reports
+        to clients. Should be a callable taking the report as its only
+        parameter.
+    :return: A WSGI app.
+    """
+    def oops_middleware(environ, start_response):
+        """OOPS inserting middleware.
+
+        This has the following WSGI properties:
+        * start_response is buffered until either write() is called, or the
+          wrapped app starts yielding content.
+        * Exceptions that are ignored by the oops config get re-raised.
+        * socket errors and GeneratorExit errors are passed through without
+        * being forward to the oops system.
+        """
+        state = {}
+        def oops_write(bytes):
+            write = state.get('write')
+            if write is None:
+                status, headers = state.pop('response')
+                # Signal that we have called start_response
+                state['write'] = start_response(status, headers)
+                write = state['write']
+            write(bytes)
+        def oops_start_response(status, headers):
+            state['response'] = (status, headers)
+            return oops_write
+        try:
+            iterator = iter(app(environ, oops_start_response))
+            for bytes in iterator:
+                if 'write' not in state:
+                    status, headers = state.pop('response')
+                    # Signal that we have called start_response
+                    state['write'] = start_response(status, headers)
+                yield bytes
+        except socket.error:
+            raise
+        except GeneratorExit:
+            raise
+        except Exception:
+            exc_info = sys.exc_info()
+            report = config.create(dict(exc_info=exc_info))
+            ids = config.publish(report)
+            if not ids or 'write' in state:
+                # No OOPS generated, no oops publisher, or we have already
+                # transmitted the wrapped apps headers - either way we can't
+                # replace the content with a clean error, so let the wsgi
+                # server figure it out.
+                raise
+            start_response('500 Internal Server Error',
+                [('Content-Type', content_type)])
+            if error_render is not None:
+                yield error_render(report)
+            else:
+                yield template % report
+
+    return oops_middleware

=== modified file 'oops_wsgi/tests/__init__.py'
--- oops_wsgi/tests/__init__.py	2011-08-17 06:36:18 +0000
+++ oops_wsgi/tests/__init__.py	2011-08-17 09:53:30 +0000
@@ -21,6 +21,7 @@
 
 def test_suite():
     test_mod_names = [
+        'middleware',
         ]
     return TestLoader().loadTestsFromNames(
         ['oops_wsgi.tests.test_' + name for name in test_mod_names])

=== added file 'oops_wsgi/tests/test_middleware.py'
--- oops_wsgi/tests/test_middleware.py	1970-01-01 00:00:00 +0000
+++ oops_wsgi/tests/test_middleware.py	2011-08-17 09:53:30 +0000
@@ -0,0 +1,235 @@
+# 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 middleware."""
+
+import errno
+from doctest import ELLIPSIS
+import gc
+import socket
+from textwrap import dedent
+
+from oops import (
+    Config,
+    createhooks,
+    )
+from testtools import TestCase
+from testtools.matchers import (
+    DocTestMatches,
+    raises,
+    )
+
+from oops_wsgi import make_app
+
+
+class TestMakeApp(TestCase):
+
+    def setUp(self):
+        super(TestMakeApp, self).setUp()
+        self.calls = []
+
+    def wrap_and_run(self, inner, config, failing_write=False,
+            failing_start=False, failing_server_write=False, params=None):
+        if not params:
+            params = {}
+        app = make_app(inner, config, **params)
+        def start_response(status, headers):
+            if failing_start:
+                raise socket.error(errno.EPIPE, 'Connection closed')
+            self.calls.append((status, headers))
+            if failing_write:
+                def fail(bytes):
+                    raise socket.error(errno.EPIPE, 'Connection closed')
+                return fail
+            else:
+                return self.calls.append
+        if not failing_server_write:
+            return ''.join(list(app({}, start_response)))
+        else:
+            iterator = iter(app({}, start_response))
+            # get one item from it, which is enough to ensure we've activated
+            # all the frames.
+            step = iterator.next()
+            # the client pipe is closed or something - we discard the iterator
+            del iterator
+            gc.collect()
+            return step
+
+    def test_make_app_returns_app(self):
+        def inner(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            self.calls.append('inner')
+            yield ''
+        self.wrap_and_run(inner, Config())
+        self.assertEqual([
+                # the start_reponse gets buffered.
+                'inner',
+                ('200 OK', [('Content-Type', 'text/html')]),
+                ], self.calls)
+
+    def test_unpublished_exception_raises(self):
+        def inner(environ, start_response):
+            raise ValueError('foo')
+            yield ''
+        self.assertThat(lambda:self.wrap_and_run(inner, Config()),
+            raises(ValueError('foo')))
+
+    def test_filtered_exception_raises(self):
+        def inner(environ, start_response):
+            raise ValueError('foo')
+            yield ''
+        config = Config()
+        config.filters.append(lambda report: True)
+        self.assertThat(lambda:self.wrap_and_run(inner, Config()),
+            raises(ValueError('foo')))
+
+    def publish_to_calls(self, report):
+        self.calls.append(report)
+        report['id'] = len(self.calls)
+        return report['id']
+
+    def test_write_with_socket_exceptions_raise_no_oops(self):
+        # When the wrapped app uses the 'write' function (which is
+        # deprecated..., socket errors will raise within the oops middleware
+        # but should be ignored.
+        def inner(environ, start_response):
+            write = start_response('200 OK', [('Content-Type', 'text/html')])
+            write('')
+        config = Config()
+        config.publishers.append(self.publish_to_calls)
+        self.assertThat(lambda:self.wrap_and_run(inner, config,
+            failing_write=True), raises(socket.error))
+        self.assertEqual([
+                ('200 OK', [('Content-Type', 'text/html')]),
+                ], self.calls)
+
+    def test_GeneratorExit_while_iterating_raise_no_oops(self):
+        # if the wgsi server encounters a dropped socket, any iterators will be
+        # interrupted with GeneratorExit, and this is normal - its not caught
+        # or put into the OOPS system.
+        def inner(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            while True:
+                yield ''
+        config = Config()
+        config.publishers.append(self.publish_to_calls)
+        self.assertEqual('', self.wrap_and_run(inner, config,
+            failing_server_write=True))
+        self.assertEqual([
+                ('200 OK', [('Content-Type', 'text/html')]),
+                ], self.calls)
+
+    def test_socket_error_from_start_response_raise_no_oops(self):
+        def inner(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            yield ''
+        config = Config()
+        config.publishers.append(self.publish_to_calls)
+        self.assertThat(lambda:self.wrap_and_run(inner, config,
+            failing_start=True), raises(socket.error))
+        self.assertEqual([], self.calls)
+
+    def test_start_response_not_called_if_fails_after_start_no_oops(self):
+        # oops middle ware needs to buffer the start_response call in case the
+        # wrapped app blows up before it starts streaming data - otherwise the
+        # response cannot be replaced. If the exception is one that would not
+        # oops (e.g. no publishers), start_response is never called.
+        def inner(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            raise ValueError('boom, yo')
+            yield ''
+        config = Config()
+        self.assertThat(lambda:self.wrap_and_run(inner, config,),
+                raises(ValueError('boom, yo')))
+        self.assertEqual([], self.calls)
+
+    def config_for_oopsing(self):
+        config = Config()
+        # datestamps are hell on tests
+        config.on_create.remove(createhooks.attach_date)
+        # so are file paths
+        def remove_tb_text(report, context):
+            report.pop('tb_text')
+        config.on_create.append(remove_tb_text)
+        config.publishers.append(self.publish_to_calls)
+        return config
+
+    def test_start_response_500_if_fails_after_start_before_body(self):
+        # oops middle ware needs to buffer the start_response call in case the
+        # wrapped app blows up before it starts streaming data - otherwise the
+        # response cannot be replaced. If the exception is one that would
+        # oops start_response is only called with 500.
+        def inner(environ, start_response):
+            start_response('200 OK', [('Content-Type', 'text/html')])
+            raise ValueError('boom, yo')
+            yield ''
+        config = self.config_for_oopsing()
+        iterated = self.wrap_and_run(inner, config)
+        self.assertEqual([
+            # First the oops is generated
+            {'id': 1,
+             'type': 'ValueError',
+             'value': u'boom, yo'},
+            # Then the middleware responses
+            ('500 Internal Server Error', [('Content-Type', 'text/html')]),
+            ], self.calls)
+        self.assertThat(iterated, DocTestMatches(dedent("""\
+            <html>
+            <head><title>Oops! - ...</title></head>
+            <body>
+            <h1>Oops!</h1>
+            <p>Something broke while generating the page.
+            Please try again in a few minutes, and if the problem persists file
+            a bug or contact customer support. PLease quote OOPS-ID
+            <strong>...</strong>
+            </p></body></html>
+            """),ELLIPSIS))
+
+    def test_custom_template_content_type(self):
+        def inner(environ, start_response):
+            raise ValueError('boom, yo')
+        config = self.config_for_oopsing()
+        iterated = self.wrap_and_run(inner, config,
+                params=dict(content_type='text/json',
+                template='{"oopsid" : "%(id)s"}'))
+        self.assertEqual([
+            # First the oops is generated
+            {'id': 1,
+             'type': 'ValueError',
+             'value': u'boom, yo'},
+            # Then the middleware responses
+            ('500 Internal Server Error', [('Content-Type', 'text/json')]),
+            ], self.calls)
+        self.assertThat(iterated, DocTestMatches(dedent("""\
+            {"oopsid" : "..."}"""), ELLIPSIS))
+
+    def test_custom_renderer(self):
+        def inner(environ, start_response):
+            raise ValueError('boom, yo')
+        config = self.config_for_oopsing()
+        def error_render(report):
+            return "woo"
+        iterated = self.wrap_and_run(inner, config,
+                params=dict(error_render=error_render))
+        self.assertEqual([
+            # First the oops is generated
+            {'id': 1,
+             'type': 'ValueError',
+             'value': u'boom, yo'},
+            # Then the middleware responses
+            ('500 Internal Server Error', [('Content-Type', 'text/html')]),
+            ], self.calls)
+        self.assertEqual(iterated, 'woo')

=== modified file 'setup.py'
--- setup.py	2011-08-17 06:36:18 +0000
+++ setup.py	2011-08-17 09:53:30 +0000
@@ -41,6 +41,7 @@
           ],
       install_requires = [
           'oops',
+          'paste',
           ],
       extras_require = dict(
           test=[

=== modified file 'versions.cfg'
--- versions.cfg	2011-08-17 06:36:18 +0000
+++ versions.cfg	2011-08-17 09:53:30 +0000
@@ -5,6 +5,7 @@
 fixtures = 0.3.6
 iso8601 = 0.1.4
 oops = 0.0.6
+paste = 1.7.2
 pytz = 2010o
 setuptools = 0.6c11
 testtools = 0.9.11