From d96ff9144f98bb44254f77f56e55967c46b09774 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sun, 8 Aug 2010 07:25:28 +0000 Subject: [PATCH] - New public interface: ``repoze.bfg.exceptions.IExceptionResponse``. This interface is provided by all internal exception classes (such as ``repoze.bfg.exceptions.NotFound`` and ``repoze.bfg.exceptions.Forbidden``), instances of which are both exception objects and can behave as WSGI response objects. This interface is made public so that exception classes which are also valid WSGI response factories can be configured to implement them or exception instances which are also or response instances can be configured to provide them. - New API class: ``repoze.bfg.view.AppendSlashNotFoundViewFactory`` (undoes previous custom_notfound_view on request passsed to append_slash_notfound_view). - Previously, two default view functions were registered at Configurator setup (one for ``repoze.bfg.exceptions.NotFound`` named ``default_notfound_view`` and one for ``repoze.bfg.exceptions.Forbidden`` named ``default_forbidden_view``) to render internal exception responses. Those default view functions have been removed, replaced with a generic default view function which is registered at Configurator setup for the ``repoze.bfg.interfaces.IExceptionResponse`` interface that simply returns the exception instance; the ``NotFound` and ``Forbidden`` classes are now still exception factories but they are also response factories which generate instances that implement the new ``repoze.bfg.interfaces.IExceptionResponse`` interface. --- CHANGES.txt | 78 ++++++---- docs/api/interfaces.rst | 8 + docs/api/view.rst | 3 +- docs/narr/urldispatch.rst | 39 +++++ docs/whatsnew-1.3.rst | 48 ++++-- repoze/bfg/configuration.py | 17 ++- repoze/bfg/exceptions.py | 68 +++++++-- repoze/bfg/interfaces.py | 22 ++- repoze/bfg/tests/test_configuration.py | 57 ++++++-- repoze/bfg/tests/test_exceptions.py | 45 ++++++ repoze/bfg/tests/test_view.py | 119 +++++++-------- repoze/bfg/view.py | 193 +++++++++++-------------- 12 files changed, 442 insertions(+), 255 deletions(-) create mode 100644 repoze/bfg/tests/test_exceptions.py diff --git a/CHANGES.txt b/CHANGES.txt index 65a08a781d..351d284df4 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,42 +4,48 @@ Next release Features -------- -- There can only be one Not Found view in any ``repoze.bfg`` - application. If you use +- New public interface: ``repoze.bfg.exceptions.IExceptionResponse``. + This interface is provided by all internal exception classes (such + as ``repoze.bfg.exceptions.NotFound`` and + ``repoze.bfg.exceptions.Forbidden``), instances of which are both + exception objects and can behave as WSGI response objects. This + interface is made public so that exception classes which are also + valid WSGI response factories can be configured to implement them + or exception instances which are also or response instances can be + configured to provide them. + +- New API class: ``repoze.bfg.view.AppendSlashNotFoundViewFactory``. + + There can only be one Not Found view in any :mod:`repoze.bfg + application. Even if you use ``repoze.bfg.view.append_slash_notfound_view`` as the Not Found - view, it still must generate a NotFound response when it cannot - redirect to a slash-appended URL; this not found response will be - visible to site users. - - As of this release, if you wish to use a custom notfound view - callable when ``append_slash_notfound_view`` does not redirect to a - slash-appended URL, use a wrapper function as the - ``repoze.bfg.exceptions.NotFound`` view; have this wrapper attach a - view callable which returns a response to the request object named - ``custom_notfound_view`` before calling - ``append_slash_notfound_view``. For example:: - - from webob.exc import HTTPNotFound + view, ``repoze.bfg`` still must generate a ``404 Not Found`` + response when it cannot redirect to a slash-appended URL; this not + found response will be visible to site users. + + If you don't care what this 404 response looks like, and you only + need redirections to slash-appended route URLs, you may use the + ``repoze.bfg.view.append_slash_notfound_view`` object as the Not + Found view. However, if you wish to use a *custom* notfound view + callable when a URL cannot be redirected to a slash-appended URL, + you may wish to use an instance of the + ``repoze.bfg.view.AppendSlashNotFoundViewFactory`` class as the Not + Found view, supplying the notfound view callable as the first + argument to its constructor. For instance:: + from repoze.bfg.exceptions import NotFound - from repoze.bfg.view import append_slash_notfound_view + from repoze.bfg.view import AppendSlashNotFoundViewFactory - def notfound_view(exc, request): - def fallback_notfound_view(exc, request): - return HTTPNotFound('It aint there, stop trying!') - request.fallback_notfound_view = fallback_notfound_view - return append_slash_notfound_view(exc, request) + def notfound_view(context, request): + return HTTPNotFound('It aint there, stop trying!') - config.add_view(notfound_view, context=NotFound) + custom_append_slash = AppendSlashNotFoundViewFactory(notfound_view) + config.add_view(custom_append_slash, context=NotFound) - ``custom_notfound_view`` must adhere to the two-argument view + The ``notfound_view`` supplied must adhere to the two-argument view callable calling convention of ``(context, request)`` (``context`` will be the exception object). - If ``custom_notfound_view`` is not found on the request object, a - default notfound response will be generated when the - ``append_slash_notfound_view`` doesn't redirect to a slash-appended - URL. - Documentation -------------- @@ -49,6 +55,22 @@ Documentation - Expanded the "Redirecting to Slash-Appended Routes" section of the URL Dispatch narrative chapter. +Internal +-------- + +- Previously, two default view functions were registered at + Configurator setup (one for ``repoze.bfg.exceptions.NotFound`` named + ``default_notfound_view`` and one for + ``repoze.bfg.exceptions.Forbidden`` named + ``default_forbidden_view``) to render internal exception responses. + Those default view functions have been removed, replaced with a + generic default view function which is registered at Configurator + setup for the ``repoze.bfg.interfaces.IExceptionResponse`` interface + that simply returns the exception instance; the ``NotFound` and + ``Forbidden`` classes are now still exception factories but they are + also response factories which generate instances that implement the + new ``repoze.bfg.interfaces.IExceptionResponse`` interface. + 1.3a7 (2010-08-01) ================== diff --git a/docs/api/interfaces.rst b/docs/api/interfaces.rst index 3e68d2e9fd..e024fcff74 100644 --- a/docs/api/interfaces.rst +++ b/docs/api/interfaces.rst @@ -5,6 +5,9 @@ .. automodule:: repoze.bfg.interfaces +Event-Related Interfaces +++++++++++++++++++++++++ + .. autoclass:: IAfterTraversal .. autoclass:: INewRequest @@ -13,4 +16,9 @@ .. autoclass:: IWSGIApplicationCreatedEvent +Other Interfaces +++++++++++++++++ + + .. autoclass:: IExceptionResponse + diff --git a/docs/api/view.rst b/docs/api/view.rst index e345a0015d..f15f1bc0d7 100644 --- a/docs/api/view.rst +++ b/docs/api/view.rst @@ -19,6 +19,7 @@ .. autoclass:: static :members: - .. autofunction:: append_slash_notfound_view + .. autofunction:: append_slash_notfound_view(context, request) + .. autoclass:: AppendSlashNotFoundViewFactory diff --git a/docs/narr/urldispatch.rst b/docs/narr/urldispatch.rst index 6e3a68c971..dd45c8d4d4 100644 --- a/docs/narr/urldispatch.rst +++ b/docs/narr/urldispatch.rst @@ -1132,6 +1132,45 @@ general description of how to configure a not found view. .. note:: This feature is new as of :mod:`repoze.bfg` 1.1. +Custom Not Found View With Slash Appended Routes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There can only be one :term:`Not Found view` in any :mod:`repoze.bfg +application. Even if you use +:func:`repoze.bfg.view.append_slash_notfound_view` as the Not Found +view, :mod:`repoze.bfg` still must generate a ``404 Not Found`` +response when it cannot redirect to a slash-appended URL; this not +found response will be visible to site users. + +If you don't care what this 404 response looks like, and only you need +redirections to slash-appended route URLs, you may use the +:func:`repoze.bfg.view.append_slash_notfound_view` object as the Not +Found view as described above. However, if you wish to use a *custom* +notfound view callable when a URL cannot be redirected to a +slash-appended URL, you may wish to use an instance of the +:class:`repoze.bfg.view.AppendSlashNotFoundViewFactory` class as the +Not Found view, supplying a :term:`view callable` to be used as the +custom notfound view as the first argument to its constructor. For +instance: + +.. code-block:: python + + from repoze.bfg.exceptions import NotFound + from repoze.bfg.view import AppendSlashNotFoundViewFactory + + def notfound_view(context, request): + return HTTPNotFound('It aint there, stop trying!') + + custom_append_slash = AppendSlashNotFoundViewFactory(notfound_view) + config.add_view(custom_append_slash, context=NotFound) + +The ``notfound_view`` supplied must adhere to the two-argument view +callable calling convention of ``(context, request)`` (``context`` +will be the exception object). + +.. note:: The :class:`repoze.bfg.view.AppendSlashNotFoundViewFactory` + class is new as of BFG 1.3. + .. _cleaning_up_after_a_request: Cleaning Up After a Request diff --git a/docs/whatsnew-1.3.rst b/docs/whatsnew-1.3.rst index f0a7b87f78..07f63b9d64 100644 --- a/docs/whatsnew-1.3.rst +++ b/docs/whatsnew-1.3.rst @@ -235,21 +235,41 @@ Minor Feature Additions - The :func:`repoze.bfg.configuration.Configurator.add_route` API now returns the route object that was added. -- There can only be one Not Found view in any :mod:`repoze.bfg` - application. If you use +- New API class: + :class:`repoze.bfg.view.AppendSlashNotFoundViewFactory`. + + There can only be one :term:`Not Found view` in any :mod:`repoze.bfg + application. Even if you use :func:`repoze.bfg.view.append_slash_notfound_view` as the Not Found - view, it still must generate a 404 response when it cannot redirect - to a slash-appended URL; this not found response will be visible to - site users. As of this release, if you wish to use a custom - notfound view callable when - :func:`repoze.bfg.view.append_slash_notfound_view` does not redirect - to a slash-appended URL, use a wrapper function as the - :exc:`repoze.bfg.exceptions.NotFound` exception view; have this - wrapper attach a view callable which returns a response to the - request object named ``custom_notfound_view`` before calling - :func:`repoze.bfg.view.append_slash_notfound_view`. See - :func:`repoze.bfg.view.append_slash_notfound_view` for more - information. + view, :mod:`repoze.bfg` still must generate a ``404 Not Found`` + response when it cannot redirect to a slash-appended URL; this not + found response will be visible to site users. + + If you don't care what this 404 response looks like, and only you + need redirections to slash-appended route URLs, you may use the + :func:`repoze.bfg.view.append_slash_notfound_view` object as the Not + Found view. However, if you wish to use a *custom* notfound view + callable when a URL cannot be redirected to a slash-appended URL, + you may wish to use an instance of the + :class:`repoze.bfg.view.AppendSlashNotFoundViewFactory` class as the + Not Found view, supplying a :term:`view callable` to be used as the + custom notfound view as the first argument to its constructor. For + instance: + + .. code-block:: python + + from repoze.bfg.exceptions import NotFound + from repoze.bfg.view import AppendSlashNotFoundViewFactory + + def notfound_view(context, request): + return HTTPNotFound('It aint there, stop trying!') + + custom_append_slash = AppendSlashNotFoundViewFactory(notfound_view) + config.add_view(custom_append_slash, context=NotFound) + + The ``notfound_view`` supplied must adhere to the two-argument view + callable calling convention of ``(context, request)`` (``context`` + will be the exception object). Backwards Incompatibilities --------------------------- diff --git a/repoze/bfg/configuration.py b/repoze/bfg/configuration.py index f694d77379..0ab4abcb5a 100644 --- a/repoze/bfg/configuration.py +++ b/repoze/bfg/configuration.py @@ -38,6 +38,8 @@ from repoze.bfg.interfaces import ITraverser from repoze.bfg.interfaces import IView from repoze.bfg.interfaces import IViewClassifier +from repoze.bfg.interfaces import IExceptionResponse +from repoze.bfg.interfaces import IException from repoze.bfg import chameleon_text from repoze.bfg import chameleon_zpt @@ -69,8 +71,7 @@ from repoze.bfg.traversal import find_interface from repoze.bfg.urldispatch import RoutesMapper from repoze.bfg.view import render_view_to_response -from repoze.bfg.view import default_notfound_view -from repoze.bfg.view import default_forbidden_view +from repoze.bfg.view import default_exceptionresponse_view MAX_ORDER = 1 << 30 DEFAULT_PHASH = md5().hexdigest() @@ -408,8 +409,8 @@ def setup_registry(self, settings=None, root_factory=None, authorization_policy) for name, renderer in renderers: self.add_renderer(name, renderer) - self.add_view(default_notfound_view, context=NotFound) - self.add_view(default_forbidden_view, context=Forbidden) + self.add_view(default_exceptionresponse_view, + context=IExceptionResponse) if locale_negotiator: registry.registerUtility(locale_negotiator, ILocaleNegotiator) if request_factory: @@ -2312,8 +2313,12 @@ def attr_view(context, request): return attr_view def isexception(o): - return isinstance(o, Exception) or ( - inspect.isclass(o) and issubclass(o, Exception) + if IInterface.providedBy(o): + if IException.isEqualOrExtendedBy(o): + return True + return ( + isinstance(o, Exception) or + (inspect.isclass(o) and (issubclass(o, Exception))) ) # note that ``options`` is a b/w compat alias for ``settings`` and diff --git a/repoze/bfg/exceptions.py b/repoze/bfg/exceptions.py index 00cb768834..9b885d9dcb 100644 --- a/repoze/bfg/exceptions.py +++ b/repoze/bfg/exceptions.py @@ -1,7 +1,43 @@ from zope.configuration.exceptions import ConfigurationError as ZCE +from zope.interface import implements -class Forbidden(Exception): - """\ +from repoze.bfg.decorator import reify +from repoze.bfg.interfaces import IExceptionResponse +import cgi + +class ExceptionResponse(Exception): + """ Abstract class to support behaving as a WSGI response object """ + implements(IExceptionResponse) + status = None + + def __init__(self, message=''): + Exception.__init__(self, message) # B / C + self.message = message + + @reify # defer execution until asked explicitly + def app_iter(self): + return [ + """ + + %s + +

%s

+ %s + + + """ % (self.status, self.status, cgi.escape(self.message)) + ] + + @reify # defer execution until asked explicitly + def headerlist(self): + return [ + ('Content-Length', str(len(self.app_iter[0]))), + ('Content-Type', 'text/html') + ] + + +class Forbidden(ExceptionResponse): + """ Raise this exception within :term:`view` code to immediately return the :term:`forbidden view` to the invoking user. Usually this is a basic ``401`` page, but the forbidden view can be @@ -11,10 +47,12 @@ class Forbidden(Exception): which should be a string. The value of this string will be placed into the WSGI environment by the router under the ``repoze.bfg.message`` key, for availability to the - :term:`Forbidden View`.""" + :term:`Forbidden View`. + """ + status = '401 Unauthorized' -class NotFound(Exception): - """\ +class NotFound(ExceptionResponse): + """ Raise this exception within :term:`view` code to immediately return the :term:`Not Found view` to the invoking user. Usually this is a basic ``404`` page, but the Not Found view can be @@ -24,7 +62,18 @@ class NotFound(Exception): which should be a string. The value of this string will be placed into the WSGI environment by the router under the ``repoze.bfg.message`` key, for availability to the :term:`Not Found - View`.""" + View`. + """ + status = '404 Not Found' + +class PredicateMismatch(NotFound): + """ + Internal exception (not an API) raised by multiviews when no + view matches. This exception subclasses the ``NotFound`` + exception only one reason: if it reaches the main exception + handler, it should be treated like a ``NotFound`` by any exception + view registrations. + """ class URLDecodeError(UnicodeDecodeError): """ @@ -41,10 +90,3 @@ class ConfigurationError(ZCE): """ Raised when inappropriate input values are supplied to an API method of a :term:`Configurator`""" -class PredicateMismatch(NotFound): - """ Internal exception (not an API) raised by multiviews when no - view matches. This exception subclasses the ``NotFound`` - exception only one reason: if it reaches the main exception - handler, it should be treated like a ``NotFound`` by any exception - view registrations.""" - diff --git a/repoze/bfg/interfaces.py b/repoze/bfg/interfaces.py index def957dad4..086c93f3ad 100644 --- a/repoze/bfg/interfaces.py +++ b/repoze/bfg/interfaces.py @@ -24,6 +24,22 @@ class IWSGIApplicationCreatedEvent(Interface): is called.""" app = Attribute(u"Published application") +class IResponse(Interface): # not an API + status = Attribute('WSGI status code of response') + headerlist = Attribute('List of response headers') + app_iter = Attribute('Iterable representing the response body') + +class IException(Interface): # not an API + """ An interface representing a generic exception """ + +class IExceptionResponse(IException, IResponse): + """ An interface representing a WSGI response which is also an + exception object. Register an exception view using this interface + as a ``context`` to apply the registered view for all exception + types raised by :mod:`repoze.bfg` internally + (:class:`repoze.bfg.exceptions.NotFound` and + :class:`repoze.bfg.exceptions.Forbidden`).""" + # internal interfaces class IRequest(Interface): @@ -35,11 +51,6 @@ class IRouteRequest(Interface): """ *internal only* interface used as in a utility lookup to find route-specific interfaces. Not an API.""" -class IResponse(Interface): - status = Attribute('WSGI status code of response') - headerlist = Attribute('List of response headers') - app_iter = Attribute('Iterable representing the response body') - class IAuthenticationPolicy(Interface): """ An object representing a BFG authentication policy. """ def authenticated_userid(request): @@ -271,3 +282,4 @@ def __call__(request): class ITranslationDirectories(Interface): """ A list object representing all known translation directories for an application""" + diff --git a/repoze/bfg/tests/test_configuration.py b/repoze/bfg/tests/test_configuration.py index 2f001ba8d9..1eae660947 100644 --- a/repoze/bfg/tests/test_configuration.py +++ b/repoze/bfg/tests/test_configuration.py @@ -194,11 +194,9 @@ def registerUtility(self, *arg, **kw): self.assertEqual(reg.notify(1), None) self.assertEqual(reg.events, (1,)) - def test_setup_registry_registers_default_exception_views(self): - from repoze.bfg.exceptions import NotFound - from repoze.bfg.exceptions import Forbidden - from repoze.bfg.view import default_notfound_view - from repoze.bfg.view import default_forbidden_view + def test_setup_registry_registers_default_exceptionresponse_view(self): + from repoze.bfg.interfaces import IExceptionResponse + from repoze.bfg.view import default_exceptionresponse_view class DummyRegistry(object): def registerUtility(self, *arg, **kw): pass @@ -207,10 +205,25 @@ def registerUtility(self, *arg, **kw): views = [] config.add_view = lambda *arg, **kw: views.append((arg, kw)) config.setup_registry() - self.assertEqual(views[0], ((default_notfound_view,), - {'context':NotFound})) - self.assertEqual(views[1], ((default_forbidden_view,), - {'context':Forbidden})) + self.assertEqual(views[0], ((default_exceptionresponse_view,), + {'context':IExceptionResponse})) + + def test_setup_registry_explicit_notfound_trumps_iexceptionresponse(self): + from zope.interface import implementedBy + from repoze.bfg.interfaces import IRequest + from repoze.bfg.exceptions import NotFound + from repoze.bfg.registry import Registry + reg = Registry() + config = self._makeOne(reg) + config.setup_registry() # registers IExceptionResponse default view + def myview(context, request): + return 'OK' + config.add_view(myview, context=NotFound) + request = self._makeRequest(config) + view = self._getViewCallable(config, ctx_iface=implementedBy(NotFound), + request_iface=IRequest) + result = view(None, request) + self.assertEqual(result, 'OK') def test_setup_registry_custom_settings(self): from repoze.bfg.registry import Registry @@ -3702,6 +3715,32 @@ def test_deserialize_style_raises(self): self.assertEqual(e.args[0], "The dotted name 'cant.be.found' cannot be imported") +class Test_isexception(unittest.TestCase): + def _callFUT(self, ob): + from repoze.bfg.configuration import isexception + return isexception(ob) + + def test_is_exception_instance(self): + class E(Exception): + pass + e = E() + self.assertEqual(self._callFUT(e), True) + + def test_is_exception_class(self): + class E(Exception): + pass + self.assertEqual(self._callFUT(E), True) + + def test_is_IException(self): + from repoze.bfg.interfaces import IException + self.assertEqual(self._callFUT(IException), True) + + def test_is_IException_subinterface(self): + from repoze.bfg.interfaces import IException + class ISubException(IException): + pass + self.assertEqual(self._callFUT(ISubException), True) + class DummyRequest: subpath = () def __init__(self): diff --git a/repoze/bfg/tests/test_exceptions.py b/repoze/bfg/tests/test_exceptions.py new file mode 100644 index 0000000000..4091eb9412 --- /dev/null +++ b/repoze/bfg/tests/test_exceptions.py @@ -0,0 +1,45 @@ +import unittest + +class TestExceptionResponse(unittest.TestCase): + def _makeOne(self, message): + from repoze.bfg.exceptions import ExceptionResponse + return ExceptionResponse(message) + + def test_app_iter(self): + exc = self._makeOne('') + self.failUnless('' in exc.app_iter[0]) + + def test_headerlist(self): + exc = self._makeOne('') + headerlist = exc.headerlist + headerlist.sort() + app_iter = exc.app_iter + clen = str(len(app_iter[0])) + self.assertEqual(headerlist[0], ('Content-Length', clen)) + self.assertEqual(headerlist[1], ('Content-Type', 'text/html')) + + def test_withmessage(self): + exc = self._makeOne('abc&123') + self.failUnless('abc&123' in exc.app_iter[0]) + +class TestNotFound(unittest.TestCase): + def _makeOne(self, message): + from repoze.bfg.exceptions import NotFound + return NotFound(message) + + def test_it(self): + from repoze.bfg.exceptions import ExceptionResponse + e = self._makeOne('notfound') + self.failUnless(isinstance(e, ExceptionResponse)) + self.assertEqual(e.status, '404 Not Found') + +class TestForbidden(unittest.TestCase): + def _makeOne(self, message): + from repoze.bfg.exceptions import Forbidden + return Forbidden(message) + + def test_it(self): + from repoze.bfg.exceptions import ExceptionResponse + e = self._makeOne('unauthorized') + self.failUnless(isinstance(e, ExceptionResponse)) + self.assertEqual(e.status, '401 Unauthorized') diff --git a/repoze/bfg/tests/test_view.py b/repoze/bfg/tests/test_view.py index 27f468c744..18a46a2059 100644 --- a/repoze/bfg/tests/test_view.py +++ b/repoze/bfg/tests/test_view.py @@ -342,59 +342,7 @@ def foo(): pass self.assertEqual(settings[0]['renderer'], 'repoze.bfg.tests:fixtures/minimal.pt') -class TestDefaultView(BaseTest): - def test_no_registry_on_request(self): - request = None - context = Exception() - response = self._callFUT(context, request) - self.assertEqual(response.status, self.status) - self.failUnless('' in response.body) - - def test_nomessage(self): - request = self._makeRequest() - context = Exception() - response = self._callFUT(context, request) - self.assertEqual(response.status, self.status) - self.failUnless('' in response.body) - - def test_withmessage(self): - request = self._makeRequest() - context = Exception('abc&123') - response = self._callFUT(context, request) - self.assertEqual(response.status, self.status) - self.failUnless('abc&123' in response.body) - - def test_context_not_exception(self): - request = self._makeRequest() - request.exception = Exception('woo') - context = None - response = self._callFUT(context, request) - self.assertEqual(response.status, self.status) - self.failUnless('woo' in response.body) - - def test_msg_exception_raised(self): - request = self._makeRequest() - context = None - response = self._callFUT(context, request) - self.assertEqual(response.status, self.status) - self.failUnless('' in response.body) - -class TestDefaultForbiddenView(TestDefaultView, unittest.TestCase): - status = '401 Unauthorized' - - def _callFUT(self, context, request): - from repoze.bfg.view import default_forbidden_view - return default_forbidden_view(context, request) - - -class TestDefaultNotFoundView(TestDefaultView, unittest.TestCase): - status = '404 Not Found' - - def _callFUT(self, context, request): - from repoze.bfg.view import default_notfound_view - return default_notfound_view(context, request) - -class AppendSlashNotFoundView(BaseTest, unittest.TestCase): +class Test_append_slash_notfound_view(BaseTest, unittest.TestCase): def _callFUT(self, context, request): from repoze.bfg.view import append_slash_notfound_view return append_slash_notfound_view(context, request) @@ -417,49 +365,81 @@ def get_routes(self): def test_context_is_not_exception(self): request = self._makeRequest(PATH_INFO='/abc') - request.exception = Exception('halloo') + request.exception = ExceptionResponse() context = DummyContext() response = self._callFUT(context, request) self.assertEqual(response.status, '404 Not Found') - self.failUnless('halloo' in response.body) + self.assertEqual(response.app_iter, ['Not Found']) def test_no_mapper(self): request = self._makeRequest(PATH_INFO='/abc') - context = Exception() + context = ExceptionResponse() response = self._callFUT(context, request) self.assertEqual(response.status, '404 Not Found') - def test_custom_notfound_view(self): - request = self._makeRequest(PATH_INFO='/abc') - def notfound(exc, request): - return 'abc' - request.custom_notfound_view = notfound - context = Exception() - response = self._callFUT(context, request) - self.assertEqual(response, 'abc') - def test_no_path(self): request = self._makeRequest() - context = Exception() + context = ExceptionResponse() self._registerMapper(request.registry, True) response = self._callFUT(context, request) self.assertEqual(response.status, '404 Not Found') def test_mapper_path_already_slash_ending(self): request = self._makeRequest(PATH_INFO='/abc/') - context = Exception() + context = ExceptionResponse() self._registerMapper(request.registry, True) response = self._callFUT(context, request) self.assertEqual(response.status, '404 Not Found') def test_matches(self): request = self._makeRequest(PATH_INFO='/abc') - context = Exception() + context = ExceptionResponse() self._registerMapper(request.registry, True) response = self._callFUT(context, request) self.assertEqual(response.status, '302 Found') self.assertEqual(response.location, '/abc/') +class TestAppendSlashNotFoundViewFactory(BaseTest, unittest.TestCase): + def _makeOne(self, notfound_view): + from repoze.bfg.view import AppendSlashNotFoundViewFactory + return AppendSlashNotFoundViewFactory(notfound_view) + + def test_custom_notfound_view(self): + request = self._makeRequest(PATH_INFO='/abc') + context = ExceptionResponse() + def custom_notfound(context, request): + return 'OK' + view = self._makeOne(custom_notfound) + response = view(context, request) + self.assertEqual(response, 'OK') + +class Test_default_exceptionresponse_view(unittest.TestCase): + def _callFUT(self, context, request): + from repoze.bfg.view import default_exceptionresponse_view + return default_exceptionresponse_view(context, request) + + def test_is_exception(self): + context = Exception() + result = self._callFUT(context, None) + self.failUnless(result is context) + + def test_is_not_exception_no_request_exception(self): + context = object() + request = DummyRequest() + result = self._callFUT(context, request) + self.failUnless(result is context) + + def test_is_not_exception_request_exception(self): + context = object() + request = DummyRequest() + request.exception = 'abc' + result = self._callFUT(context, request) + self.assertEqual(result, 'abc') + +class ExceptionResponse(Exception): + status = '404 Not Found' + app_iter = ['Not Found'] + headerlist = [] class DummyContext: pass @@ -469,6 +449,9 @@ def view(context, request): return response return view +class DummyRequest: + pass + class DummyResponse: status = '200 OK' headerlist = () diff --git a/repoze/bfg/view.py b/repoze/bfg/view.py index 30076b7754..1d18395309 100644 --- a/repoze/bfg/view.py +++ b/repoze/bfg/view.py @@ -1,4 +1,3 @@ -import cgi import mimetypes import os @@ -12,7 +11,6 @@ if hasattr(mimetypes, 'init'): mimetypes.init() -from webob import Response from webob.exc import HTTPFound import venusian @@ -20,7 +18,6 @@ from zope.deprecation import deprecated from zope.interface import providedBy -from repoze.bfg.interfaces import IResponseFactory from repoze.bfg.interfaces import IRoutesMapper from repoze.bfg.interfaces import IView from repoze.bfg.interfaces import IViewClassifier @@ -447,129 +444,103 @@ def callback(context, name, ob): return wrapped -def default_view(context, request, status): +def default_exceptionresponse_view(context, request): if not isinstance(context, Exception): - # backwards compat for a default_view registered via + # backwards compat for an exception response view registered via # config.set_notfound_view or config.set_forbidden_view # instead of as a proper exception view - context = getattr(request, 'exception', None) - try: - msg = cgi.escape('%s' % context.args[0]) - except Exception: - msg = '' - html = """ - - %s - -

%s

- %s - - - """ % (status, status, msg) - headers = [('Content-Length', str(len(html))), - ('Content-Type', 'text/html')] - try: - registry = request.registry - except AttributeError: - registry = get_current_registry() - response_factory = registry.queryUtility(IResponseFactory, - default=Response) - return response_factory(status = status, - headerlist = headers, - app_iter = [html]) - -def default_forbidden_view(context, request): - return default_view(context, request, '401 Unauthorized') - -def default_notfound_view(context, request): - return default_view(context, request, '404 Not Found') - -def append_slash_notfound_view(context, request): - """For behavior like Django's ``APPEND_SLASH=True``, use this view - as the :term:`Not Found view` in your application. - - When this view is the Not Found view (indicating that no view was - found), and any routes have been defined in the configuration of - your application, if the value of the ``PATH_INFO`` WSGI - environment variable does not already end in a slash, and if the - value of ``PATH_INFO`` *plus* a slash matches any route's path, do - an HTTP redirect to the slash-appended PATH_INFO. Note that this - will *lose* ``POST`` data information (turning it into a GET), so - you shouldn't rely on this to redirect POST requests. - - If you use :term:`ZCML`, add the following to your application's - ``configure.zcml`` to use this view as the Not Found view:: + context = getattr(request, 'exception', context) + return context + +class AppendSlashNotFoundViewFactory(object): + """ There can only be one :term:`Not Found view` in any + :mod:`repoze.bfg application. Even if you use + :func:`repoze.bfg.view.append_slash_notfound_view` as the Not + Found view, :mod:`repoze.bfg` still must generate a ``404 Not + Found`` response when it cannot redirect to a slash-appended URL; + this not found response will be visible to site users. + + If you don't care what this 404 response looks like, and you only + need redirections to slash-appended route URLs, you may use the + :func:`repoze.bfg.view.append_slash_notfound_view` object as the + Not Found view. However, if you wish to use a *custom* notfound + view callable when a URL cannot be redirected to a slash-appended + URL, you may wish to use an instance of this class as the Not + Found view, supplying a :term:`view callable` to be used as the + custom notfound view as the first argument to its constructor. + For instance: - + .. code-block:: python - Or use the - :meth:`repoze.bfg.configuration.Configurator.add_view` - method if you don't use ZCML:: + from repoze.bfg.exceptions import NotFound + from repoze.bfg.view import AppendSlashNotFoundViewFactory - from repoze.bfg.exceptions import NotFound - from repoze.bfg.view import append_slash_notfound_view - config.add_view(append_slash_notfound_view, context=NotFound) + def notfound_view(context, request): + return HTTPNotFound('It aint there, stop trying!') - See also :ref:`changing_the_notfound_view`. + custom_append_slash = AppendSlashNotFoundViewFactory(notfound_view) + config.add_view(custom_append_slash, context=NotFound) - .. note:: This function is new as of :mod:`repoze.bfg` version 1.1. + The ``notfound_view`` supplied must adhere to the two-argument + view callable calling convention of ``(context, request)`` + (``context`` will be the exception object). - There can only be one Not Found view in any :mod:`repoze.bfg - application. If you use ``append_slash_notfound_view`` as the Not - Found view, it still must generate a NotFound response when it - cannot redirect to a slash-appended URL; this not found response - will be visible to site users. + .. note:: This class is new as of :mod:`repoze.bfg` version 1.3. - If you wish to use a custom notfound view callable when - ``append_slash_notfound_view`` does not redirect to a - slash-appended URL, use a wrapper function as the - :exc:`repoze.bfg.exceptions.NotFound` view; have this wrapper - attach a :term:`view callable` which returns a response to the - request object named ``custom_notfound_view`` before calling - ``append_slash_notfound_view``. For example: + """ + def __init__(self, notfound_view=None): + if notfound_view is None: + notfound_view = default_exceptionresponse_view + self.notfound_view = notfound_view + + def __call__(self, context, request): + if not isinstance(context, Exception): + # backwards compat for an append_notslash_view registered via + # config.set_notfound_view instead of as a proper exception view + context = getattr(request, 'exception', None) + path = request.environ.get('PATH_INFO', '/') + registry = request.registry + mapper = registry.queryUtility(IRoutesMapper) + if mapper is not None and not path.endswith('/'): + slashpath = path + '/' + for route in mapper.get_routes(): + if route.match(slashpath) is not None: + return HTTPFound(location=slashpath) + return self.notfound_view(context, request) + +append_slash_notfound_view = AppendSlashNotFoundViewFactory() +append_slash_notfound_view.__doc__ = """\ +For behavior like Django's ``APPEND_SLASH=True``, use this view as the +:term:`Not Found view` in your application. + +When this view is the Not Found view (indicating that no view was +found), and any routes have been defined in the configuration of your +application, if the value of the ``PATH_INFO`` WSGI environment +variable does not already end in a slash, and if the value of +``PATH_INFO`` *plus* a slash matches any route's path, do an HTTP +redirect to the slash-appended PATH_INFO. Note that this will *lose* +``POST`` data information (turning it into a GET), so you shouldn't +rely on this to redirect POST requests. + +If you use :term:`ZCML`, add the following to your application's +``configure.zcml`` to use this view as the Not Found view:: - .. code-block:: python + - from webob.exc import HTTPNotFound - from repoze.bfg.exceptions import NotFound - from repoze.bfg.view import append_slash_notfound_view +Or use the +:meth:`repoze.bfg.configuration.Configurator.add_view` +method if you don't use ZCML:: - def notfound_view(exc, request): - def fallback_notfound_view(exc, request): - return HTTPNotFound('It aint there, stop trying!') - request.fallback_notfound_view = fallback_notfound_view - return append_slash_notfound_view(exc, request) + from repoze.bfg.exceptions import NotFound + from repoze.bfg.view import append_slash_notfound_view + config.add_view(append_slash_notfound_view, context=NotFound) - config.add_view(notfound_view, context=NotFound) +See also :ref:`changing_the_notfound_view`. - ``custom_notfound_view`` must adhere to the two-argument view - callable calling convention of ``(context, request)`` (``context`` - will be the exception object). +.. note:: This function is new as of :mod:`repoze.bfg` version 1.1. +""" - If ``custom_notfound_view`` is not found on the request object, a - default notfound response will be generated when the - ``append_slash_notfound_view`` doesn't redirect to a - slash-appended URL. - .. note:: The checking for ``request.custom_notfound_view`` by - ``append_slash_notfound_view`` is new as of :mod:`repoze.bfg` - version 1.3. - """ - if not isinstance(context, Exception): - # backwards compat for an append_notslash_view registered via - # config.set_notfound_view instead of as a proper exception view - context = getattr(request, 'exception', None) - path = request.environ.get('PATH_INFO', '/') - registry = request.registry - mapper = registry.queryUtility(IRoutesMapper) - if mapper is not None and not path.endswith('/'): - slashpath = path + '/' - for route in mapper.get_routes(): - if route.match(slashpath) is not None: - return HTTPFound(location=slashpath) - notfound_view = getattr(request, 'custom_notfound_view', - default_notfound_view) - return notfound_view(context, request)