From b0d20b5c3fe7df472633899024cdab685483807a Mon Sep 17 00:00:00 2001 From: Amos Latteier Date: Thu, 2 Jun 2016 14:50:53 -0700 Subject: add exception_only argument to add_view to only register exception views. --- pyramid/config/views.py | 25 +++++++++++++++++++++---- pyramid/tests/test_config/test_views.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 9e46ba155..198fde5e8 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -213,6 +213,7 @@ class ViewsConfiguratorMixin(object): match_param=None, check_csrf=None, require_csrf=None, + exception_only=False, **view_options): """ Add a :term:`view configuration` to the current configuration state. Arguments to ``add_view`` are broken @@ -701,6 +702,14 @@ class ViewsConfiguratorMixin(object): Support setting view deriver options. Previously, only custom view predicate values could be supplied. + exception_only + + .. versionadded:: 1.8 + + A boolean indicating whether the view is registered only as an + exception view. When this argument is true, the view context must + be an exception. + """ if custom_predicates: warnings.warn( @@ -759,6 +768,11 @@ class ViewsConfiguratorMixin(object): raise ConfigurationError( 'request_type must be an interface, not %s' % request_type) + if exception_only and not isexception(context): + raise ConfigurationError( + 'context must be an exception when exception_only is true' + ) + if context is None: context = for_ @@ -942,10 +956,13 @@ class ViewsConfiguratorMixin(object): view_iface = ISecuredView else: view_iface = IView - self.registry.registerAdapter( - derived_view, - (IViewClassifier, request_iface, context), view_iface, name - ) + if not exception_only: + self.registry.registerAdapter( + derived_view, + (IViewClassifier, request_iface, context), + view_iface, + name + ) if isexc: self.registry.registerAdapter( derived_view, diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 878574e88..c93175ff9 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -1815,6 +1815,38 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, configure_view) + def test_add_view_exception_only_no_regular_view(self): + from zope.interface import implementedBy + from pyramid.renderers import null_renderer + view1 = lambda *arg: 'OK' + config = self._makeOne(autocommit=True) + config.add_view(view=view1, context=Exception, renderer=null_renderer, + exception_only=True) + view = self._getViewCallable(config, ctx_iface=implementedBy(Exception)) + self.assertTrue(view is None) + + def test_add_view_exception_only(self): + from zope.interface import implementedBy + from pyramid.renderers import null_renderer + view1 = lambda *arg: 'OK' + config = self._makeOne(autocommit=True) + config.add_view(view=view1, context=Exception, renderer=null_renderer, + exception_only=True) + view = self._getViewCallable( + config, ctx_iface=implementedBy(Exception), exception_view=True + ) + self.assertEqual(view1, view) + + def test_add_view_exception_only_misconfiguration(self): + view = lambda *arg: 'OK' + config = self._makeOne(autocommit=True) + class NotAnException(object): + pass + self.assertRaises( + ConfigurationError, + config.add_view, view, context=NotAnException, exception_only=True + ) + def test_derive_view_function(self): from pyramid.renderers import null_renderer def view(request): -- cgit v1.2.3 From d6c90d154b74107da325a1c45c7a2c4f35ea03c5 Mon Sep 17 00:00:00 2001 From: Amos Latteier Date: Thu, 2 Jun 2016 16:39:45 -0700 Subject: Add add_exception_view --- pyramid/config/views.py | 65 +++++++++++++++++++++++++ pyramid/tests/test_config/test_views.py | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 198fde5e8..b13885833 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1613,6 +1613,71 @@ class ViewsConfiguratorMixin(object): set_notfound_view = add_notfound_view # deprecated sorta-bw-compat alias + @viewdefaults + @action_method + def add_exception_view( + self, + view=None, + context=None, + attr=None, + renderer=None, + wrapper=None, + route_name=None, + request_type=None, + request_method=None, + request_param=None, + containment=None, + xhr=None, + accept=None, + header=None, + path_info=None, + custom_predicates=(), + decorator=None, + mapper=None, + match_param=None, + **view_options + ): + """ Add a view for an exception to the current configuration state. + The view will be called when Pyramid or application code raises an + the given exception. + + .. versionadded:: 1.8 + """ + for arg in ( + 'name', 'permission', 'for_', 'http_cache', + 'require_csrf', 'exception_only', + ): + if arg in view_options: + raise ConfigurationError( + '%s may not be used as an argument to add_exception_view' + % arg + ) + if context is None: + raise ConfigurationError('context exception must be specified') + settings = dict( + view=view, + context=context, + wrapper=wrapper, + renderer=renderer, + request_type=request_type, + request_method=request_method, + request_param=request_param, + containment=containment, + xhr=xhr, + accept=accept, + header=header, + path_info=path_info, + custom_predicates=custom_predicates, + decorator=decorator, + mapper=mapper, + match_param=match_param, + route_name=route_name, + permission=NO_PERMISSION_REQUIRED, + require_csrf=False, + exception_only=True, + ) + return self.add_view(**settings) + @action_method def set_view_mapper(self, mapper): """ diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index c93175ff9..1adde9225 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -1847,6 +1847,90 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view, view, context=NotAnException, exception_only=True ) + def test_add_exception_view(self): + from zope.interface import implementedBy + from pyramid.interfaces import IRequest + from pyramid.renderers import null_renderer + view1 = lambda *arg: 'OK' + config = self._makeOne(autocommit=True) + config.add_exception_view(view=view1, context=Exception, renderer=null_renderer) + wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(Exception), exception_view=True, + ) + context = Exception() + request = self._makeRequest(config) + self.assertEqual(wrapper(context, request), 'OK') + + def test_add_exception_view_disallows_name(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_exception_view, + context=Exception(), + name='foo') + + def test_add_exception_view_disallows_permission(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_exception_view, + context=Exception(), + permission='foo') + + def test_add_exception_view_disallows_for_(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_exception_view, + context=Exception(), + for_='foo') + + def test_add_exception_view_disallows_http_cache(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_exception_view, + context=Exception(), + http_cache='foo') + + def test_add_exception_view_disallows_exception_only(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_exception_view, + context=Exception(), + exception_only=True) + + def test_add_exception_view_requires_context(self): + config = self._makeOne(autocommit=True) + view = lambda *a: 'OK' + self.assertRaises(ConfigurationError, + config.add_exception_view, view=view) + + def test_add_exception_view_with_view_defaults(self): + from pyramid.renderers import null_renderer + from pyramid.exceptions import PredicateMismatch + from pyramid.httpexceptions import HTTPNotFound + from zope.interface import directlyProvides + from zope.interface import implementedBy + class view(object): + __view_defaults__ = { + 'containment':'pyramid.tests.test_config.IDummy' + } + def __init__(self, request): + pass + def __call__(self): + return 'OK' + config = self._makeOne(autocommit=True) + config.add_exception_view( + view=view, + context=Exception, + renderer=null_renderer) + wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(Exception), exception_view=True) + context = DummyContext() + directlyProvides(context, IDummy) + request = self._makeRequest(config) + self.assertEqual(wrapper(context, request), 'OK') + context = DummyContext() + request = self._makeRequest(config) + self.assertRaises(PredicateMismatch, wrapper, context, request) + def test_derive_view_function(self): from pyramid.renderers import null_renderer def view(request): -- cgit v1.2.3 From 93c94b9ea69d25da63604e494f89d8c619e7babb Mon Sep 17 00:00:00 2001 From: Amos Latteier Date: Thu, 2 Jun 2016 17:03:18 -0700 Subject: Add exception_view_config decorator. --- pyramid/tests/test_view.py | 46 +++++++++++++++++++++++++++++++++++++++-- pyramid/view.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/pyramid/tests/test_view.py b/pyramid/tests/test_view.py index 2de44d579..d18c6eca4 100644 --- a/pyramid/tests/test_view.py +++ b/pyramid/tests/test_view.py @@ -132,7 +132,49 @@ class Test_forbidden_view_config(BaseTest, unittest.TestCase): self.assertEqual(settings[0]['view'], None) # comes from call_venusian self.assertEqual(settings[0]['attr'], 'view') self.assertEqual(settings[0]['_info'], 'codeinfo') - + +class Test_exception_view_config(BaseTest, unittest.TestCase): + def _makeOne(self, **kw): + from pyramid.view import exception_view_config + return exception_view_config(**kw) + + def test_ctor(self): + inst = self._makeOne(context=Exception, path_info='path_info') + self.assertEqual(inst.__dict__, + {'context':Exception, 'path_info':'path_info'}) + + def test_it_function(self): + def view(request): pass + decorator = self._makeOne(context=Exception, renderer='renderer') + venusian = DummyVenusian() + decorator.venusian = venusian + wrapped = decorator(view) + self.assertTrue(wrapped is view) + config = call_venusian(venusian) + settings = config.settings + self.assertEqual( + settings, + [{'venusian': venusian, 'context': Exception, + 'renderer': 'renderer', '_info': 'codeinfo', 'view': None}] + ) + + def test_it_class(self): + decorator = self._makeOne() + venusian = DummyVenusian() + decorator.venusian = venusian + decorator.venusian.info.scope = 'class' + class view(object): pass + wrapped = decorator(view) + self.assertTrue(wrapped is view) + config = call_venusian(venusian) + settings = config.settings + self.assertEqual(len(settings), 1) + self.assertEqual(len(settings[0]), 4) + self.assertEqual(settings[0]['venusian'], venusian) + self.assertEqual(settings[0]['view'], None) # comes from call_venusian + self.assertEqual(settings[0]['attr'], 'view') + self.assertEqual(settings[0]['_info'], 'codeinfo') + class RenderViewToResponseTests(BaseTest, unittest.TestCase): def _callFUT(self, *arg, **kw): from pyramid.view import render_view_to_response @@ -898,7 +940,7 @@ class DummyConfig(object): def add_view(self, **kw): self.settings.append(kw) - add_notfound_view = add_forbidden_view = add_view + add_notfound_view = add_forbidden_view = add_exception_view = add_view def with_package(self, pkg): self.pkg = pkg diff --git a/pyramid/view.py b/pyramid/view.py index 88c6397af..5a9f2a068 100644 --- a/pyramid/view.py +++ b/pyramid/view.py @@ -463,6 +463,57 @@ class forbidden_view_config(object): settings['_info'] = info.codeinfo # fbo "action_method" return wrapped +class exception_view_config(object): + """ + .. versionadded:: 1.8 + + An analogue of :class:`pyramid.view.view_config` which registers an + exception view. + + The exception_view_config constructor requires an exception context, and + additionally accepts most of the same argumenta as the constructor of + :class:`pyramid.view.view_config`. It can be used in the same places, + and behaves in largely the same way, except it always registers an exception + view instead of a 'normal' view. + + Example: + + .. code-block:: python + + from pyramid.view import exception_view_config + from pyramid.response import Response + + @exception_view_config(context=ValueError) + def error_view(request): + return Response('A value error ocurred') + + All arguments passed to this function have the same meaning as + :meth:`pyramid.view.view_config` and each predicate argument restricts + the set of circumstances under which this exception view will be invoked. + """ + + def __init__(self, **settings): + self.__dict__.update(settings) + + def __call__(self, wrapped): + settings = self.__dict__.copy() + + def callback(context, name, ob): + config = context.config.with_package(info.module) + config.add_exception_view(view=ob, **settings) + + info = self.venusian.attach(wrapped, callback, category='pyramid') + + if info.scope == 'class': + # if the decorator was attached to a method in a class, or + # otherwise executed at class scope, we need to set an + # 'attr' into the settings if one isn't already in there + if settings.get('attr') is None: + settings['attr'] = wrapped.__name__ + + settings['_info'] = info.codeinfo # fbo "action_method" + return wrapped + def _find_views( registry, request_iface, -- cgit v1.2.3 From 74842ab6a6025d5058da4321658e7f51eb697ea0 Mon Sep 17 00:00:00 2001 From: Amos Latteier Date: Thu, 2 Jun 2016 17:18:59 -0700 Subject: fix broken decorator. --- pyramid/view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyramid/view.py b/pyramid/view.py index 5a9f2a068..74c57d272 100644 --- a/pyramid/view.py +++ b/pyramid/view.py @@ -491,6 +491,7 @@ class exception_view_config(object): :meth:`pyramid.view.view_config` and each predicate argument restricts the set of circumstances under which this exception view will be invoked. """ + venusian = venusian def __init__(self, **settings): self.__dict__.update(settings) -- cgit v1.2.3 From 3fd41dcb5a94a73f43862f6d5c063af7b54e6ff3 Mon Sep 17 00:00:00 2001 From: Amos Latteier Date: Thu, 30 Jun 2016 10:08:16 -0700 Subject: fix docs typo, expand example. --- pyramid/view.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyramid/view.py b/pyramid/view.py index 74c57d272..1895de96d 100644 --- a/pyramid/view.py +++ b/pyramid/view.py @@ -471,7 +471,7 @@ class exception_view_config(object): exception view. The exception_view_config constructor requires an exception context, and - additionally accepts most of the same argumenta as the constructor of + additionally accepts most of the same arguments as the constructor of :class:`pyramid.view.view_config`. It can be used in the same places, and behaves in largely the same way, except it always registers an exception view instead of a 'normal' view. @@ -483,9 +483,9 @@ class exception_view_config(object): from pyramid.view import exception_view_config from pyramid.response import Response - @exception_view_config(context=ValueError) - def error_view(request): - return Response('A value error ocurred') + @exception_view_config(context=ValueError, renderer='json') + def error_view(context, request): + return {'error': str(context)} All arguments passed to this function have the same meaning as :meth:`pyramid.view.view_config` and each predicate argument restricts -- cgit v1.2.3 From e8c66a339e9f7d83bd2408952de53ef30dba0794 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 19 Sep 2016 23:52:05 -0500 Subject: derive exception views separately from normal views - previously the multiview was shared for both exception and hot-route, but now that we allow some exception-only views this needed to be separated - add ViewDeriverInfo.exception_only to detect exception views - do not prevent http_cache on exception views - optimize secured_view and csrf_view derivers to remove themselves from the view pipeline for exception views --- docs/narr/hooks.rst | 23 ++- docs/narr/viewconfig.rst | 19 +- docs/narr/views.rst | 40 ++-- pyramid/config/views.py | 319 ++++++++++++++++++-------------- pyramid/exceptions.py | 1 + pyramid/interfaces.py | 1 + pyramid/tests/test_config/test_views.py | 258 +++++++++++++++++++------- pyramid/tests/test_exceptions.py | 2 - pyramid/tests/test_view.py | 13 +- pyramid/tests/test_viewderivers.py | 22 +++ pyramid/view.py | 39 ++-- pyramid/viewderivers.py | 57 +++--- 12 files changed, 522 insertions(+), 272 deletions(-) diff --git a/docs/narr/hooks.rst b/docs/narr/hooks.rst index 49ef29d3f..7fbac2080 100644 --- a/docs/narr/hooks.rst +++ b/docs/narr/hooks.rst @@ -1639,7 +1639,8 @@ the user-defined :term:`view callable`: Enforce the ``permission`` defined on the view. This element is a no-op if no permission is defined. Note there will always be a permission defined if a default permission was assigned via - :meth:`pyramid.config.Configurator.set_default_permission`. + :meth:`pyramid.config.Configurator.set_default_permission` unless the + view is an :term:`exception view`. This element will also output useful debugging information when ``pyramid.debug_authorization`` is enabled. @@ -1649,7 +1650,8 @@ the user-defined :term:`view callable`: Used to check the CSRF token provided in the request. This element is a no-op if ``require_csrf`` view option is not ``True``. Note there will always be a ``require_csrf`` option if a default value was assigned via - :meth:`pyramid.config.Configurator.set_default_csrf_options`. + :meth:`pyramid.config.Configurator.set_default_csrf_options` unless + the view is an :term:`exception view`. ``owrapped_view`` @@ -1695,6 +1697,8 @@ around monitoring and security. In order to register a custom :term:`view deriver`, you should create a callable that conforms to the :class:`pyramid.interfaces.IViewDeriver` interface, and then register it with your application using :meth:`pyramid.config.Configurator.add_view_deriver`. +The callable should accept the ``view`` to be wrapped and the ``info`` object +which is an instance of :class:`pyramid.interfaces.IViewDeriverInfo`. For example, below is a callable that can provide timing information for the view pipeline: @@ -1745,6 +1749,21 @@ View derivers are unique in that they have access to most of the options passed to :meth:`pyramid.config.Configurator.add_view` in order to decide what to do, and they have a chance to affect every view in the application. +.. _exception_view_derivers: + +Exception Views and View Derivers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A :term:`view deriver` has the opportunity to wrap any view, including +an :term:`exception view`. In general this is fine, but certain view derivers +may wish to avoid doing certain things when handling exceptions. For example, +the ``csrf_view`` and ``secured_view`` built-in view derivers will not perform +security checks on exception views unless explicitly told to do so. + +You can check for ``info.exception_only`` on the +:class:`pyramid.interfaces.IViewDeriverInfo` object when wrapping the view +to determine whether you are wrapping an exception view or a normal view. + Ordering View Derivers ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/narr/viewconfig.rst b/docs/narr/viewconfig.rst index cd5b8feb0..76eaf3cc5 100644 --- a/docs/narr/viewconfig.rst +++ b/docs/narr/viewconfig.rst @@ -34,7 +34,7 @@ determine the set of circumstances which must be true for the view callable to be invoked. A view configuration statement is made about information present in the -:term:`context` resource and the :term:`request`. +:term:`context` resource (or exception) and the :term:`request`. View configuration is performed in one of two ways: @@ -306,9 +306,26 @@ configured view. represented class or if the :term:`context` resource provides the represented interface; it is otherwise false. + It is possible to pass an exception class as the context if your context may + subclass an exception. In this case **two** views will be registered. One + will match normal incoming requests and the other will match as an + :term:`exception view` which only occurs when an exception is raised during + the normal request processing pipeline. + If ``context`` is not supplied, the value ``None``, which matches any resource, is used. +``exception_only`` + + When this value is ``True`` the ``context`` argument must be a subclass of + ``Exception``. This flag indicates that only an :term:`exception view` should + be created and that this view should not match if the traversal + :term:`context` matches the ``context`` argument. If the ``context`` is a + subclass of ``Exception`` and this value is ``False`` (the default) then a + view will be registered to match the traversal :term:`context` as well. + + .. versionadded:: 1.8 + ``route_name`` If ``route_name`` is supplied, the view callable will be invoked only when the named route has matched. diff --git a/docs/narr/views.rst b/docs/narr/views.rst index 770d27919..465062651 100644 --- a/docs/narr/views.rst +++ b/docs/narr/views.rst @@ -262,10 +262,16 @@ specialized views as described in :ref:`special_exceptions_in_callables` can also be used by application developers to convert arbitrary exceptions to responses. -To register a view that should be called whenever a particular exception is -raised from within :app:`Pyramid` view code, use the exception class (or one of -its superclasses) as the :term:`context` of a view configuration which points -at a view callable for which you'd like to generate a response. +To register a :term:`exception view` that should be called whenever a +particular exception is raised from within :app:`Pyramid` view code, use +:meth:`pyramid.config.Configurator.add_exception_view` to register a view +configuration which matches the exception (or a subclass of the exception) and +points at a view callable for which you'd like to generate a response. The +exception will be passed as the ``context`` argument to any +:term:`view predicate` registered with the view as well as to the view itself. +For convenience a new decorator exists, +:class:`pyramid.views.exception_view_config`, which may be used to easily +register exception views. For example, given the following exception class in a module named ``helloworld.exceptions``: @@ -277,17 +283,16 @@ For example, given the following exception class in a module named def __init__(self, msg): self.msg = msg - You can wire a view callable to be called whenever any of your *other* code raises a ``helloworld.exceptions.ValidationFailure`` exception: .. code-block:: python :linenos: - from pyramid.view import view_config + from pyramid.view import exception_view_config from helloworld.exceptions import ValidationFailure - @view_config(context=ValidationFailure) + @exception_view_config(ValidationFailure) def failed_validation(exc, request): response = Response('Failed validation: %s' % exc.msg) response.status_int = 500 @@ -308,7 +313,7 @@ view registration: from pyramid.view import view_config from helloworld.exceptions import ValidationFailure - @view_config(context=ValidationFailure, route_name='home') + @exception_view_config(ValidationFailure, route_name='home') def failed_validation(exc, request): response = Response('Failed validation: %s' % exc.msg) response.status_int = 500 @@ -327,14 +332,21 @@ which have a name will be ignored. .. note:: - Normal (i.e., non-exception) views registered against a context resource type - which inherits from :exc:`Exception` will work normally. When an exception - view configuration is processed, *two* views are registered. One as a - "normal" view, the other as an "exception" view. This means that you can use - an exception as ``context`` for a normal view. + In most cases, you should register an :term:`exception view` by using + :meth:`pyramid.config.Configurator.add_exception_view`. However, it is + possible to register 'normal' (i.e., non-exception) views against a context + resource type which inherits from :exc:`Exception` (i.e., + ``config.add_view(context=Exception)``). When the view configuration is + processed, *two* views are registered. One as a "normal" view, the other + as an :term:`exception view`. This means that you can use an exception as + ``context`` for a normal view. + + The view derivers that wrap these two views may behave differently. + See :ref:`exception_view_derivers` for more information about this. Exception views can be configured with any view registration mechanism: -``@view_config`` decorator or imperative ``add_view`` styles. +``@exception_view_config`` decorator or imperative ``add_exception_view`` +styles. .. note:: diff --git a/pyramid/config/views.py b/pyramid/config/views.py index e341922d3..ae180fb10 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -9,12 +9,11 @@ from zope.interface import ( implementedBy, implementer, ) - from zope.interface.interfaces import IInterface from pyramid.interfaces import ( - IException, IExceptionViewClassifier, + IException, IMultiView, IPackageOverrides, IRendererFactory, @@ -503,7 +502,20 @@ class ViewsConfiguratorMixin(object): if the :term:`context` provides the represented interface; it is otherwise false. This argument may also be provided to ``add_view`` as ``for_`` (an older, still-supported - spelling). + spelling). If the view should **only** match when handling + exceptions then set the ``exception_only`` to ``True``. + + exception_only + + .. versionadded:: 1.8 + + When this value is ``True`` the ``context`` argument must be + a subclass of ``Exception``. This flag indicates that only an + :term:`exception view` should be created and that this view should + not match if the traversal :term:`context` matches the ``context`` + argument. If the ``context`` is a subclass of ``Exception`` and + this value is ``False`` (the default) then a view will be + registered to match the traversal :term:`context` as well. route_name @@ -685,7 +697,7 @@ class ViewsConfiguratorMixin(object): obsoletes this argument, but it is kept around for backwards compatibility. - view_options: + view_options Pass a key/value pair here to use a third-party predicate or set a value for a view deriver. See @@ -702,14 +714,6 @@ class ViewsConfiguratorMixin(object): Support setting view deriver options. Previously, only custom view predicate values could be supplied. - exception_only - - .. versionadded:: 1.8 - - A boolean indicating whether the view is registered only as an - exception view. When this argument is true, the view context must - be an exception. - """ if custom_predicates: warnings.warn( @@ -768,14 +772,15 @@ class ViewsConfiguratorMixin(object): raise ConfigurationError( 'request_type must be an interface, not %s' % request_type) - if exception_only and not isexception(context): - raise ConfigurationError( - 'context must be an exception when exception_only is true' - ) - if context is None: context = for_ + isexc = isexception(context) + if exception_only and not isexc: + raise ConfigurationError( + 'view "context" must be an exception type when ' + '"exception_only" is True') + r_context = context if r_context is None: r_context = Interface @@ -811,6 +816,7 @@ class ViewsConfiguratorMixin(object): # is. It can't be computed any sooner because thirdparty # predicates/view derivers may not yet exist when add_view is # called. + predlist = self.get_predlist('view') valid_predicates = predlist.names() pvals = {} dvals = {} @@ -849,6 +855,7 @@ class ViewsConfiguratorMixin(object): view_intr.update(dict( name=name, context=context, + exception_only=exception_only, containment=containment, request_param=request_param, request_methods=request_method, @@ -868,7 +875,6 @@ class ViewsConfiguratorMixin(object): )) view_intr.update(view_options) introspectables.append(view_intr) - predlist = self.get_predlist('view') def register(permission=permission, renderer=renderer): request_iface = IRequest @@ -891,12 +897,54 @@ class ViewsConfiguratorMixin(object): registry=self.registry ) + renderer_type = getattr(renderer, 'type', None) + intrspc = self.introspector + if ( + renderer_type is not None and + tmpl_intr is not None and + intrspc is not None and + intrspc.get('renderer factories', renderer_type) is not None + ): + # allow failure of registered template factories to be deferred + # until view execution, like other bad renderer factories; if + # we tried to relate this to an existing renderer factory + # without checking if it the factory actually existed, we'd end + # up with a KeyError at startup time, which is inconsistent + # with how other bad renderer registrations behave (they throw + # a ValueError at view execution time) + tmpl_intr.relate('renderer factories', renderer.type) + + # make a new view separately for normal and exception paths + if not exception_only: + derived_view = derive_view(False, renderer) + register_view(IViewClassifier, request_iface, derived_view) + if isexc: + derived_exc_view = derive_view(True, renderer) + register_view(IExceptionViewClassifier, request_iface, + derived_exc_view) + + if exception_only: + derived_view = derived_exc_view + + # if there are two derived views, combine them into one for + # introspection purposes + if not exception_only and isexc: + derived_view = runtime_exc_view(derived_view, derived_exc_view) + + derived_view.__discriminator__ = lambda *arg: discriminator + # __discriminator__ is used by superdynamic systems + # that require it for introspection after manual view lookup; + # see also MultiView.__discriminator__ + view_intr['derived_callable'] = derived_view + + self.registry._clear_view_lookup_cache() + + def derive_view(isexc_only, renderer): # added by discrim_func above during conflict resolving preds = view_intr['predicates'] order = view_intr['order'] phash = view_intr['phash'] - # __no_permission_required__ handled by _secure_view derived_view = self._derive_view( view, route_name=route_name, @@ -904,6 +952,7 @@ class ViewsConfiguratorMixin(object): predicates=preds, attr=attr, context=context, + exception_only=isexc_only, renderer=renderer, wrapper_viewname=wrapper, viewname=name, @@ -916,14 +965,9 @@ class ViewsConfiguratorMixin(object): require_csrf=require_csrf, extra_options=ovals, ) - derived_view.__discriminator__ = lambda *arg: discriminator - # __discriminator__ is used by superdynamic systems - # that require it for introspection after manual view lookup; - # see also MultiView.__discriminator__ - view_intr['derived_callable'] = derived_view - - registered = self.registry.adapters.registered + return derived_view + def register_view(classifier, request_iface, derived_view): # A multiviews is a set of views which are registered for # exactly the same context type/request type/name triad. Each # consituent view in a multiview differs only by the @@ -943,32 +987,27 @@ class ViewsConfiguratorMixin(object): # matches on all the arguments it receives. old_view = None + order, phash = view_intr['order'], view_intr['phash'] + registered = self.registry.adapters.registered for view_type in (IView, ISecuredView, IMultiView): - old_view = registered((IViewClassifier, request_iface, - r_context), view_type, name) + old_view = registered( + (classifier, request_iface, r_context), + view_type, name) if old_view is not None: break - isexc = isexception(context) - def regclosure(): if hasattr(derived_view, '__call_permissive__'): view_iface = ISecuredView else: view_iface = IView - if not exception_only: - self.registry.registerAdapter( - derived_view, - (IViewClassifier, request_iface, context), - view_iface, - name - ) - if isexc: - self.registry.registerAdapter( - derived_view, - (IExceptionViewClassifier, request_iface, context), - view_iface, name) + self.registry.registerAdapter( + derived_view, + (classifier, request_iface, context), + view_iface, + name + ) is_multiview = IMultiView.providedBy(old_view) old_phash = getattr(old_view, '__phash__', DEFAULT_PHASH) @@ -1005,39 +1044,12 @@ class ViewsConfiguratorMixin(object): for view_type in (IView, ISecuredView): # unregister any existing views self.registry.adapters.unregister( - (IViewClassifier, request_iface, r_context), + (classifier, request_iface, r_context), view_type, name=name) - if isexc: - self.registry.adapters.unregister( - (IExceptionViewClassifier, request_iface, - r_context), view_type, name=name) self.registry.registerAdapter( multiview, - (IViewClassifier, request_iface, context), + (classifier, request_iface, context), IMultiView, name=name) - if isexc: - self.registry.registerAdapter( - multiview, - (IExceptionViewClassifier, request_iface, context), - IMultiView, name=name) - - self.registry._clear_view_lookup_cache() - renderer_type = getattr(renderer, 'type', None) # gard against None - intrspc = self.introspector - if ( - renderer_type is not None and - tmpl_intr is not None and - intrspc is not None and - intrspc.get('renderer factories', renderer_type) is not None - ): - # allow failure of registered template factories to be deferred - # until view execution, like other bad renderer factories; if - # we tried to relate this to an existing renderer factory - # without checking if it the factory actually existed, we'd end - # up with a KeyError at startup time, which is inconsistent - # with how other bad renderer registrations behave (they throw - # a ValueError at view execution time) - tmpl_intr.relate('renderer factories', renderer.type) if mapper: mapper_intr = self.introspectable( @@ -1351,7 +1363,8 @@ class ViewsConfiguratorMixin(object): viewname=None, accept=None, order=MAX_ORDER, phash=DEFAULT_PHASH, decorator=None, route_name=None, mapper=None, http_cache=None, context=None, - require_csrf=None, extra_options=None): + require_csrf=None, exception_only=False, + extra_options=None): view = self.maybe_dotted(view) mapper = self.maybe_dotted(mapper) if isinstance(renderer, string_types): @@ -1389,6 +1402,7 @@ class ViewsConfiguratorMixin(object): registry=self.registry, package=self.package, predicates=predicates, + exception_only=exception_only, options=options, ) @@ -1443,21 +1457,25 @@ class ViewsConfiguratorMixin(object): argument restricts the set of circumstances under which this forbidden view will be invoked. Unlike :meth:`pyramid.config.Configurator.add_view`, this method will raise - an exception if passed ``name``, ``permission``, ``context``, - ``for_``, or ``http_cache`` keyword arguments. These argument values - make no sense in the context of a forbidden view. + an exception if passed ``name``, ``permission``, ``require_csrf``, + ``context``, ``for_`` or ``exception_only`` keyword arguments. These + argument values make no sense in the context of a forbidden + :term:`exception view`. .. versionadded:: 1.3 + + .. versionchanged:: 1.8 + + The view is created using ``exception_only=True``. """ for arg in ( - 'name', 'permission', 'context', 'for_', 'http_cache', - 'require_csrf', + 'name', 'permission', 'context', 'for_', 'require_csrf', + 'exception_only', ): if arg in view_options: raise ConfigurationError( '%s may not be used as an argument to add_forbidden_view' - % arg - ) + % (arg,)) if view is None: view = default_exceptionresponse_view @@ -1465,6 +1483,7 @@ class ViewsConfiguratorMixin(object): settings = dict( view=view, context=HTTPForbidden, + exception_only=True, wrapper=wrapper, request_type=request_type, request_method=request_method, @@ -1513,9 +1532,9 @@ class ViewsConfiguratorMixin(object): append_slash=False, **view_options ): - """ Add a default Not Found View to the current configuration state. - The view will be called when Pyramid or application code raises an - :exc:`pyramid.httpexceptions.HTTPNotFound` exception (e.g. when a + """ Add a default :term:`Not Found View` to the current configuration + state. The view will be called when Pyramid or application code raises + an :exc:`pyramid.httpexceptions.HTTPNotFound` exception (e.g. when a view cannot be found for the request). The simplest example is: .. code-block:: python @@ -1533,9 +1552,9 @@ class ViewsConfiguratorMixin(object): argument restricts the set of circumstances under which this notfound view will be invoked. Unlike :meth:`pyramid.config.Configurator.add_view`, this method will raise - an exception if passed ``name``, ``permission``, ``context``, - ``for_``, or ``http_cache`` keyword arguments. These argument values - make no sense in the context of a Not Found View. + an exception if passed ``name``, ``permission``, ``require_csrf``, + ``context``, ``for_``, or ``exception_only`` keyword arguments. These + argument values make no sense in the context of a Not Found View. If ``append_slash`` is ``True``, when this Not Found View is invoked, and the current path info does not end in a slash, the notfound logic @@ -1562,18 +1581,22 @@ class ViewsConfiguratorMixin(object): being used, :class:`~pyramid.httpexceptions.HTTPMovedPermanently will be used` for the redirect response if a slash-appended route is found. - .. versionchanged:: 1.6 .. versionadded:: 1.3 + + .. versionchanged:: 1.6 + + .. versionchanged:: 1.8 + + The view is created using ``exception_only=True``. """ for arg in ( - 'name', 'permission', 'context', 'for_', 'http_cache', - 'require_csrf', + 'name', 'permission', 'context', 'for_', 'require_csrf', + 'exception_only', ): if arg in view_options: raise ConfigurationError( '%s may not be used as an argument to add_notfound_view' - % arg - ) + % (arg,)) if view is None: view = default_exceptionresponse_view @@ -1581,6 +1604,7 @@ class ViewsConfiguratorMixin(object): settings = dict( view=view, context=HTTPNotFound, + exception_only=True, wrapper=wrapper, request_type=request_type, request_method=request_method, @@ -1621,64 +1645,40 @@ class ViewsConfiguratorMixin(object): self, view=None, context=None, - attr=None, - renderer=None, - wrapper=None, - route_name=None, - request_type=None, - request_method=None, - request_param=None, - containment=None, - xhr=None, - accept=None, - header=None, - path_info=None, - custom_predicates=(), - decorator=None, - mapper=None, - match_param=None, + # force all other arguments to be specified as key=value **view_options - ): - """ Add a view for an exception to the current configuration state. - The view will be called when Pyramid or application code raises an - the given exception. + ): + """ Add an :term:`exception view` for the specified ``exception`` to + the current configuration state. The view will be called when Pyramid + or application code raises the given exception. + + This method accepts accepts almost all of the same arguments as + :meth:`pyramid.config.Configurator.add_view` except for ``name``, + ``permission``, ``for_``, ``require_csrf`` and ``exception_only``. + + By default, this method will set ``context=Exception`` thus + registering for most default Python exceptions. Any subclass of + ``Exception`` may be specified. .. versionadded:: 1.8 """ for arg in ( - 'name', 'permission', 'for_', 'http_cache', - 'require_csrf', 'exception_only', + 'name', 'for_', 'exception_only', 'require_csrf', 'permission', ): if arg in view_options: raise ConfigurationError( '%s may not be used as an argument to add_exception_view' - % arg - ) + % (arg,)) if context is None: - raise ConfigurationError('context exception must be specified') - settings = dict( + context = Exception + view_options.update(dict( view=view, context=context, - wrapper=wrapper, - renderer=renderer, - request_type=request_type, - request_method=request_method, - request_param=request_param, - containment=containment, - xhr=xhr, - accept=accept, - header=header, - path_info=path_info, - custom_predicates=custom_predicates, - decorator=decorator, - mapper=mapper, - match_param=match_param, - route_name=route_name, + exception_only=True, permission=NO_PERMISSION_REQUIRED, require_csrf=False, - exception_only=True, - ) - return self.add_view(**settings) + )) + return self.add_view(**view_options) @action_method def set_view_mapper(self, mapper): @@ -1859,14 +1859,63 @@ def isexception(o): (inspect.isclass(o) and (issubclass(o, Exception))) ) +def runtime_exc_view(view, excview): + # create a view callable which can pretend to be both a normal view + # and an exception view, dispatching to the appropriate one based + # on the state of request.exception + def wrapper_view(context, request): + if getattr(request, 'exception', None): + return excview(context, request) + return view(context, request) + + # these constants are the same between the two views + wrapper_view.__wraps__ = wrapper_view + wrapper_view.__original_view__ = getattr(view, '__original_view__', view) + wrapper_view.__module__ = view.__module__ + wrapper_view.__doc__ = view.__doc__ + wrapper_view.__name__ = view.__name__ + + wrapper_view.__accept__ = getattr(view, '__accept__', None) + wrapper_view.__order__ = getattr(view, '__order__', MAX_ORDER) + wrapper_view.__phash__ = getattr(view, '__phash__', DEFAULT_PHASH) + wrapper_view.__view_attr__ = getattr(view, '__view_attr__', None) + wrapper_view.__permission__ = getattr(view, '__permission__', None) + + def wrap_fn(attr): + def wrapper(context, request): + if getattr(request, 'exception', None): + selected_view = excview + else: + selected_view = view + fn = getattr(selected_view, attr, None) + if fn is not None: + return fn(context, request) + return wrapper + + # these methods are dynamic per-request and should dispatch to their + # respective views based on whether it's an exception or not + wrapper_view.__call_permissive__ = wrap_fn('__call_permissive__') + wrapper_view.__permitted__ = wrap_fn('__permitted__') + wrapper_view.__predicated__ = wrap_fn('__predicated__') + wrapper_view.__predicates__ = wrap_fn('__predicates__') + return wrapper_view + @implementer(IViewDeriverInfo) class ViewDeriverInfo(object): - def __init__(self, view, registry, package, predicates, options): + def __init__(self, + view, + registry, + package, + predicates, + exception_only, + options, + ): self.original_view = view self.registry = registry self.package = package self.predicates = predicates or [] self.options = options or {} + self.exception_only = exception_only @reify def settings(self): diff --git a/pyramid/exceptions.py b/pyramid/exceptions.py index a8a10f927..c95922eb0 100644 --- a/pyramid/exceptions.py +++ b/pyramid/exceptions.py @@ -109,6 +109,7 @@ class ConfigurationExecutionError(ConfigurationError): def __str__(self): return "%s: %s\n in:\n %s" % (self.etype, self.evalue, self.info) + class CyclicDependencyError(Exception): """ The exception raised when the Pyramid topological sorter detects a cyclic dependency.""" diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index b252d0f4a..114f802aa 100644 --- a/pyramid/interfaces.py +++ b/pyramid/interfaces.py @@ -1234,6 +1234,7 @@ class IViewDeriverInfo(Interface): 'default values that were not overriden') predicates = Attribute('The list of predicates active on the view') original_view = Attribute('The original view object being wrapped') + exception_only = Attribute('The view will only be invoked for exceptions') class IViewDerivers(Interface): """ Interface for view derivers list """ diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 1adde9225..f020485de 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -20,15 +20,16 @@ class TestViewsConfigurationMixin(unittest.TestCase): config = Configurator(*arg, **kw) return config - def _getViewCallable(self, config, ctx_iface=None, request_iface=None, - name='', exception_view=False): + def _getViewCallable(self, config, ctx_iface=None, exc_iface=None, + request_iface=None, name=''): from zope.interface import Interface from pyramid.interfaces import IRequest from pyramid.interfaces import IView from pyramid.interfaces import IViewClassifier from pyramid.interfaces import IExceptionViewClassifier - if exception_view: + if exc_iface: classifier = IExceptionViewClassifier + ctx_iface = exc_iface else: classifier = IViewClassifier if ctx_iface is None: @@ -489,7 +490,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=newview, xhr=True, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertFalse(IMultiView.providedBy(wrapper)) request = DummyRequest() request.is_xhr = True @@ -533,7 +534,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=newview, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertFalse(IMultiView.providedBy(wrapper)) request = DummyRequest() request.is_xhr = True @@ -581,7 +582,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=newview, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertFalse(IMultiView.providedBy(wrapper)) request = DummyRequest() request.is_xhr = True @@ -626,7 +627,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertTrue(IMultiView.providedBy(wrapper)) self.assertEqual(wrapper(None, None), 'OK') @@ -669,7 +670,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): ISecuredView, name='') config.add_view(view=view, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertTrue(IMultiView.providedBy(wrapper)) self.assertEqual(wrapper(None, None), 'OK') @@ -755,7 +756,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view2, accept='text/html', context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertTrue(IMultiView.providedBy(wrapper)) self.assertEqual(len(wrapper.views), 1) self.assertEqual(len(wrapper.media_views), 1) @@ -816,7 +817,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view2, context=RuntimeError, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) + config, exc_iface=implementedBy(RuntimeError)) self.assertTrue(IMultiView.providedBy(wrapper)) self.assertEqual(len(wrapper.views), 1) self.assertEqual(len(wrapper.media_views), 1) @@ -843,31 +844,71 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertEqual([x[:2] for x in wrapper.views], [(view2, None)]) self.assertEqual(wrapper(None, None), 'OK1') - def test_add_view_exc_multiview_replaces_multiview(self): + def test_add_view_exc_multiview_replaces_multiviews(self): from pyramid.renderers import null_renderer from zope.interface import implementedBy from pyramid.interfaces import IRequest from pyramid.interfaces import IMultiView from pyramid.interfaces import IViewClassifier from pyramid.interfaces import IExceptionViewClassifier - view = DummyMultiView() + hot_view = DummyMultiView() + exc_view = DummyMultiView() config = self._makeOne(autocommit=True) config.registry.registerAdapter( - view, + hot_view, (IViewClassifier, IRequest, implementedBy(RuntimeError)), IMultiView, name='') config.registry.registerAdapter( - view, + exc_view, (IExceptionViewClassifier, IRequest, implementedBy(RuntimeError)), IMultiView, name='') view2 = lambda *arg: 'OK2' config.add_view(view=view2, context=RuntimeError, renderer=null_renderer) - wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), exception_view=True) - self.assertTrue(IMultiView.providedBy(wrapper)) - self.assertEqual([x[:2] for x in wrapper.views], [(view2, None)]) - self.assertEqual(wrapper(None, None), 'OK1') + hot_wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(RuntimeError)) + self.assertTrue(IMultiView.providedBy(hot_wrapper)) + self.assertEqual([x[:2] for x in hot_wrapper.views], [(view2, None)]) + self.assertEqual(hot_wrapper(None, None), 'OK1') + + exc_wrapper = self._getViewCallable( + config, exc_iface=implementedBy(RuntimeError)) + self.assertTrue(IMultiView.providedBy(exc_wrapper)) + self.assertEqual([x[:2] for x in exc_wrapper.views], [(view2, None)]) + self.assertEqual(exc_wrapper(None, None), 'OK1') + + def test_add_view_exc_multiview_replaces_only_exc_multiview(self): + from pyramid.renderers import null_renderer + from zope.interface import implementedBy + from pyramid.interfaces import IRequest + from pyramid.interfaces import IMultiView + from pyramid.interfaces import IViewClassifier + from pyramid.interfaces import IExceptionViewClassifier + hot_view = DummyMultiView() + exc_view = DummyMultiView() + config = self._makeOne(autocommit=True) + config.registry.registerAdapter( + hot_view, + (IViewClassifier, IRequest, implementedBy(RuntimeError)), + IMultiView, name='') + config.registry.registerAdapter( + exc_view, + (IExceptionViewClassifier, IRequest, implementedBy(RuntimeError)), + IMultiView, name='') + view2 = lambda *arg: 'OK2' + config.add_view(view=view2, context=RuntimeError, exception_only=True, + renderer=null_renderer) + hot_wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(RuntimeError)) + self.assertTrue(IMultiView.providedBy(hot_wrapper)) + self.assertEqual(len(hot_wrapper.views), 0) + self.assertEqual(hot_wrapper(None, None), 'OK1') + + exc_wrapper = self._getViewCallable( + config, exc_iface=implementedBy(RuntimeError)) + self.assertTrue(IMultiView.providedBy(exc_wrapper)) + self.assertEqual([x[:2] for x in exc_wrapper.views], [(view2, None)]) + self.assertEqual(exc_wrapper(None, None), 'OK1') def test_add_view_multiview_context_superclass_then_subclass(self): from pyramid.renderers import null_renderer @@ -886,10 +927,12 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.registry.registerAdapter( view, (IViewClassifier, IRequest, ISuper), IView, name='') config.add_view(view=view2, for_=ISub, renderer=null_renderer) - wrapper = self._getViewCallable(config, ISuper, IRequest) + wrapper = self._getViewCallable(config, ctx_iface=ISuper, + request_iface=IRequest) self.assertFalse(IMultiView.providedBy(wrapper)) self.assertEqual(wrapper(None, None), 'OK') - wrapper = self._getViewCallable(config, ISub, IRequest) + wrapper = self._getViewCallable(config, ctx_iface=ISub, + request_iface=IRequest) self.assertFalse(IMultiView.providedBy(wrapper)) self.assertEqual(wrapper(None, None), 'OK2') @@ -914,16 +957,16 @@ class TestViewsConfigurationMixin(unittest.TestCase): view, (IExceptionViewClassifier, IRequest, Super), IView, name='') config.add_view(view=view2, for_=Sub, renderer=null_renderer) wrapper = self._getViewCallable( - config, implementedBy(Super), IRequest) + config, ctx_iface=implementedBy(Super), request_iface=IRequest) wrapper_exc_view = self._getViewCallable( - config, implementedBy(Super), IRequest, exception_view=True) + config, exc_iface=implementedBy(Super), request_iface=IRequest) self.assertEqual(wrapper_exc_view, wrapper) self.assertFalse(IMultiView.providedBy(wrapper_exc_view)) self.assertEqual(wrapper_exc_view(None, None), 'OK') wrapper = self._getViewCallable( - config, implementedBy(Sub), IRequest) + config, ctx_iface=implementedBy(Sub), request_iface=IRequest) wrapper_exc_view = self._getViewCallable( - config, implementedBy(Sub), IRequest, exception_view=True) + config, exc_iface=implementedBy(Sub), request_iface=IRequest) self.assertEqual(wrapper_exc_view, wrapper) self.assertFalse(IMultiView.providedBy(wrapper_exc_view)) self.assertEqual(wrapper_exc_view(None, None), 'OK2') @@ -1233,8 +1276,8 @@ class TestViewsConfigurationMixin(unittest.TestCase): renderer=null_renderer) request_iface = self._getRouteRequestIface(config, 'foo') wrapper_exc_view = self._getViewCallable( - config, ctx_iface=implementedBy(RuntimeError), - request_iface=request_iface, exception_view=True) + config, exc_iface=implementedBy(RuntimeError), + request_iface=request_iface) self.assertNotEqual(wrapper_exc_view, None) wrapper = self._getViewCallable( config, ctx_iface=implementedBy(RuntimeError), @@ -1820,8 +1863,8 @@ class TestViewsConfigurationMixin(unittest.TestCase): from pyramid.renderers import null_renderer view1 = lambda *arg: 'OK' config = self._makeOne(autocommit=True) - config.add_view(view=view1, context=Exception, renderer=null_renderer, - exception_only=True) + config.add_view(view=view1, context=Exception, exception_only=True, + renderer=null_renderer) view = self._getViewCallable(config, ctx_iface=implementedBy(Exception)) self.assertTrue(view is None) @@ -1830,11 +1873,10 @@ class TestViewsConfigurationMixin(unittest.TestCase): from pyramid.renderers import null_renderer view1 = lambda *arg: 'OK' config = self._makeOne(autocommit=True) - config.add_view(view=view1, context=Exception, renderer=null_renderer, - exception_only=True) + config.add_view(view=view1, context=Exception, exception_only=True, + renderer=null_renderer) view = self._getViewCallable( - config, ctx_iface=implementedBy(Exception), exception_view=True - ) + config, exc_iface=implementedBy(Exception)) self.assertEqual(view1, view) def test_add_view_exception_only_misconfiguration(self): @@ -1844,23 +1886,33 @@ class TestViewsConfigurationMixin(unittest.TestCase): pass self.assertRaises( ConfigurationError, - config.add_view, view, context=NotAnException, exception_only=True - ) + config.add_view, view, context=NotAnException, exception_only=True) def test_add_exception_view(self): from zope.interface import implementedBy - from pyramid.interfaces import IRequest from pyramid.renderers import null_renderer view1 = lambda *arg: 'OK' config = self._makeOne(autocommit=True) - config.add_exception_view(view=view1, context=Exception, renderer=null_renderer) + config.add_exception_view(view=view1, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(Exception), exception_view=True, - ) + config, exc_iface=implementedBy(Exception)) context = Exception() request = self._makeRequest(config) self.assertEqual(wrapper(context, request), 'OK') + def test_add_exception_view_with_subclass(self): + from zope.interface import implementedBy + from pyramid.renderers import null_renderer + view1 = lambda *arg: 'OK' + config = self._makeOne(autocommit=True) + config.add_exception_view(view=view1, context=ValueError, + renderer=null_renderer) + wrapper = self._getViewCallable( + config, exc_iface=implementedBy(ValueError)) + context = ValueError() + request = self._makeRequest(config) + self.assertEqual(wrapper(context, request), 'OK') + def test_add_exception_view_disallows_name(self): config = self._makeOne(autocommit=True) self.assertRaises(ConfigurationError, @@ -1875,19 +1927,19 @@ class TestViewsConfigurationMixin(unittest.TestCase): context=Exception(), permission='foo') - def test_add_exception_view_disallows_for_(self): + def test_add_exception_view_disallows_require_csrf(self): config = self._makeOne(autocommit=True) self.assertRaises(ConfigurationError, config.add_exception_view, context=Exception(), - for_='foo') + require_csrf=True) - def test_add_exception_view_disallows_http_cache(self): + def test_add_exception_view_disallows_for_(self): config = self._makeOne(autocommit=True) self.assertRaises(ConfigurationError, config.add_exception_view, context=Exception(), - http_cache='foo') + for_='foo') def test_add_exception_view_disallows_exception_only(self): config = self._makeOne(autocommit=True) @@ -1896,21 +1948,14 @@ class TestViewsConfigurationMixin(unittest.TestCase): context=Exception(), exception_only=True) - def test_add_exception_view_requires_context(self): - config = self._makeOne(autocommit=True) - view = lambda *a: 'OK' - self.assertRaises(ConfigurationError, - config.add_exception_view, view=view) - def test_add_exception_view_with_view_defaults(self): from pyramid.renderers import null_renderer from pyramid.exceptions import PredicateMismatch - from pyramid.httpexceptions import HTTPNotFound from zope.interface import directlyProvides from zope.interface import implementedBy class view(object): __view_defaults__ = { - 'containment':'pyramid.tests.test_config.IDummy' + 'containment': 'pyramid.tests.test_config.IDummy' } def __init__(self, request): pass @@ -1922,7 +1967,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): context=Exception, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(Exception), exception_view=True) + config, exc_iface=implementedBy(Exception)) context = DummyContext() directlyProvides(context, IDummy) request = self._makeRequest(config) @@ -2043,7 +2088,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_forbidden_view(view, renderer=null_renderer) request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPForbidden), + exc_iface=implementedBy(HTTPForbidden), request_iface=IRequest) result = view(None, request) self.assertEqual(result, 'OK') @@ -2057,7 +2102,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_forbidden_view() request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPForbidden), + exc_iface=implementedBy(HTTPForbidden), request_iface=IRequest) context = HTTPForbidden() result = view(context, request) @@ -2080,6 +2125,11 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, config.add_forbidden_view, permission='foo') + def test_add_forbidden_view_disallows_require_csrf(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_forbidden_view, require_csrf=True) + def test_add_forbidden_view_disallows_context(self): config = self._makeOne(autocommit=True) self.assertRaises(ConfigurationError, @@ -2090,11 +2140,6 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, config.add_forbidden_view, for_='foo') - def test_add_forbidden_view_disallows_http_cache(self): - config = self._makeOne(autocommit=True) - self.assertRaises(ConfigurationError, - config.add_forbidden_view, http_cache='foo') - def test_add_forbidden_view_with_view_defaults(self): from pyramid.interfaces import IRequest from pyramid.renderers import null_renderer @@ -2115,7 +2160,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): view=view, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(HTTPForbidden), + config, exc_iface=implementedBy(HTTPForbidden), request_iface=IRequest) context = DummyContext() directlyProvides(context, IDummy) @@ -2135,7 +2180,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_notfound_view(view, renderer=null_renderer) request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPNotFound), + exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) result = view(None, request) self.assertEqual(result, (None, request)) @@ -2149,7 +2194,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_notfound_view() request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPNotFound), + exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) context = HTTPNotFound() result = view(context, request) @@ -2172,6 +2217,11 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, config.add_notfound_view, permission='foo') + def test_add_notfound_view_disallows_require_csrf(self): + config = self._makeOne(autocommit=True) + self.assertRaises(ConfigurationError, + config.add_notfound_view, require_csrf=True) + def test_add_notfound_view_disallows_context(self): config = self._makeOne(autocommit=True) self.assertRaises(ConfigurationError, @@ -2182,11 +2232,6 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, config.add_notfound_view, for_='foo') - def test_add_notfound_view_disallows_http_cache(self): - config = self._makeOne(autocommit=True) - self.assertRaises(ConfigurationError, - config.add_notfound_view, http_cache='foo') - def test_add_notfound_view_append_slash(self): from pyramid.response import Response from pyramid.renderers import null_renderer @@ -2202,7 +2247,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): request.query_string = 'a=1&b=2' request.path = '/scriptname/foo' view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPNotFound), + exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) result = view(None, request) self.assertTrue(isinstance(result, HTTPFound)) @@ -2225,7 +2270,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): request.query_string = 'a=1&b=2' request.path = '/scriptname/foo' view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPNotFound), + exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) result = view(None, request) self.assertTrue(isinstance(result, HTTPMovedPermanently)) @@ -2251,7 +2296,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): view=view, renderer=null_renderer) wrapper = self._getViewCallable( - config, ctx_iface=implementedBy(HTTPNotFound), + config, exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) context = DummyContext() directlyProvides(context, IDummy) @@ -2281,7 +2326,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): renderer='json') request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPNotFound), + exc_iface=implementedBy(HTTPNotFound), request_iface=IRequest) result = view(None, request) self._assertBody(result, '{}') @@ -2298,7 +2343,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): renderer='json') request = self._makeRequest(config) view = self._getViewCallable(config, - ctx_iface=implementedBy(HTTPForbidden), + exc_iface=implementedBy(HTTPForbidden), request_iface=IRequest) result = view(None, request) self._assertBody(result, '{}') @@ -2319,6 +2364,75 @@ class TestViewsConfigurationMixin(unittest.TestCase): from pyramid.tests import test_config self.assertEqual(result, test_config) + def test_add_normal_and_exception_view_intr_derived_callable(self): + from pyramid.renderers import null_renderer + from pyramid.exceptions import BadCSRFToken + config = self._makeOne(autocommit=True) + introspector = DummyIntrospector() + config.introspector = introspector + view = lambda r: 'OK' + config.set_default_csrf_options(require_csrf=True) + config.add_view(view, context=Exception, renderer=null_renderer) + view_intr = introspector.introspectables[1] + self.assertTrue(view_intr.type_name, 'view') + self.assertEqual(view_intr['callable'], view) + derived_view = view_intr['derived_callable'] + + request = self._makeRequest(config) + request.method = 'POST' + request.scheme = 'http' + request.POST = {} + request.headers = {} + request.session = DummySession({'csrf_token': 'foo'}) + self.assertRaises(BadCSRFToken, lambda: derived_view(None, request)) + request.exception = Exception() + self.assertEqual(derived_view(None, request), 'OK') + +class Test_runtime_exc_view(unittest.TestCase): + def _makeOne(self, view1, view2): + from pyramid.config.views import runtime_exc_view + return runtime_exc_view(view1, view2) + + def test_call(self): + def view1(context, request): return 'OK' + def view2(context, request): raise AssertionError + result_view = self._makeOne(view1, view2) + request = DummyRequest() + result = result_view(None, request) + self.assertEqual(result, 'OK') + + def test_call_dispatches_on_exception(self): + def view1(context, request): raise AssertionError + def view2(context, request): return 'OK' + result_view = self._makeOne(view1, view2) + request = DummyRequest() + request.exception = Exception() + result = result_view(None, request) + self.assertEqual(result, 'OK') + + def test_permitted(self): + def errfn(context, request): raise AssertionError + def view1(context, request): raise AssertionError + view1.__permitted__ = lambda c, r: 'OK' + def view2(context, request): raise AssertionError + view2.__permitted__ = errfn + result_view = self._makeOne(view1, view2) + request = DummyRequest() + result = result_view.__permitted__(None, request) + self.assertEqual(result, 'OK') + + def test_permitted_dispatches_on_exception(self): + def errfn(context, request): raise AssertionError + def view1(context, request): raise AssertionError + view1.__permitted__ = errfn + def view2(context, request): raise AssertionError + view2.__permitted__ = lambda c, r: 'OK' + result_view = self._makeOne(view1, view2) + request = DummyRequest() + request.exception = Exception() + result = result_view.__permitted__(None, request) + self.assertEqual(result, 'OK') + class Test_requestonly(unittest.TestCase): def _callFUT(self, view, attr=None): from pyramid.config.views import requestonly diff --git a/pyramid/tests/test_exceptions.py b/pyramid/tests/test_exceptions.py index 993209046..9cb0f58d1 100644 --- a/pyramid/tests/test_exceptions.py +++ b/pyramid/tests/test_exceptions.py @@ -90,5 +90,3 @@ class TestCyclicDependencyError(unittest.TestCase): result = str(exc) self.assertTrue("'a' sorts before ['c', 'd']" in result) self.assertTrue("'c' sorts before ['a']" in result) - - diff --git a/pyramid/tests/test_view.py b/pyramid/tests/test_view.py index d18c6eca4..cab42cf48 100644 --- a/pyramid/tests/test_view.py +++ b/pyramid/tests/test_view.py @@ -134,15 +134,24 @@ class Test_forbidden_view_config(BaseTest, unittest.TestCase): self.assertEqual(settings[0]['_info'], 'codeinfo') class Test_exception_view_config(BaseTest, unittest.TestCase): - def _makeOne(self, **kw): + def _makeOne(self, *args, **kw): from pyramid.view import exception_view_config - return exception_view_config(**kw) + return exception_view_config(*args, **kw) def test_ctor(self): inst = self._makeOne(context=Exception, path_info='path_info') self.assertEqual(inst.__dict__, {'context':Exception, 'path_info':'path_info'}) + def test_ctor_positional_exception(self): + inst = self._makeOne(Exception, path_info='path_info') + self.assertEqual(inst.__dict__, + {'context':Exception, 'path_info':'path_info'}) + + def test_ctor_positional_extras(self): + from pyramid.exceptions import ConfigurationError + self.assertRaises(ConfigurationError, lambda: self._makeOne(Exception, True)) + def test_it_function(self): def view(request): pass decorator = self._makeOne(context=Exception, renderer='renderer') diff --git a/pyramid/tests/test_viewderivers.py b/pyramid/tests/test_viewderivers.py index 79fcd6e71..676c6f66a 100644 --- a/pyramid/tests/test_viewderivers.py +++ b/pyramid/tests/test_viewderivers.py @@ -551,6 +551,28 @@ class TestDeriveView(unittest.TestCase): "'view_name' against context None): " "Allowed (NO_PERMISSION_REQUIRED)") + def test_debug_auth_permission_authpol_permitted_excview(self): + response = DummyResponse() + view = lambda *arg: response + self.config.registry.settings = dict( + debug_authorization=True, reload_templates=True) + logger = self._registerLogger() + self._registerSecurityPolicy(True) + result = self.config._derive_view( + view, context=Exception, permission='view') + self.assertEqual(view.__module__, result.__module__) + self.assertEqual(view.__doc__, result.__doc__) + self.assertEqual(view.__name__, result.__name__) + self.assertEqual(result.__call_permissive__.__wraps__, view) + request = self._makeRequest() + request.view_name = 'view_name' + request.url = 'url' + self.assertEqual(result(Exception(), request), response) + self.assertEqual(len(logger.messages), 1) + self.assertEqual(logger.messages[0], + "debug_authorization of url url (view name " + "'view_name' against context Exception()): True") + def test_secured_view_authn_policy_no_authz_policy(self): response = DummyResponse() view = lambda *arg: response diff --git a/pyramid/view.py b/pyramid/view.py index 1895de96d..2af42b1e7 100644 --- a/pyramid/view.py +++ b/pyramid/view.py @@ -17,7 +17,10 @@ from pyramid.interfaces import ( from pyramid.compat import decode_path_info -from pyramid.exceptions import PredicateMismatch +from pyramid.exceptions import ( + ConfigurationError, + PredicateMismatch, +) from pyramid.httpexceptions import ( HTTPFound, @@ -166,7 +169,7 @@ class view_config(object): :class:`pyramid.view.bfg_view`. :class:`pyramid.view.view_config` supports the following keyword - arguments: ``context``, ``permission``, ``name``, + arguments: ``context``, ``exception``, ``permission``, ``name``, ``request_type``, ``route_name``, ``request_method``, ``request_param``, ``containment``, ``xhr``, ``accept``, ``header``, ``path_info``, ``custom_predicates``, ``decorator``, ``mapper``, ``http_cache``, @@ -325,7 +328,8 @@ class notfound_view_config(object): .. versionadded:: 1.3 An analogue of :class:`pyramid.view.view_config` which registers a - :term:`Not Found View`. + :term:`Not Found View` using + :meth:`pyramid.config.Configurator.add_notfound_view`. The ``notfound_view_config`` constructor accepts most of the same arguments as the constructor of :class:`pyramid.view.view_config`. It can be used @@ -413,7 +417,8 @@ class forbidden_view_config(object): .. versionadded:: 1.3 An analogue of :class:`pyramid.view.view_config` which registers a - :term:`forbidden view`. + :term:`forbidden view` using + :meth:`pyramid.config.Configurator.add_forbidden_view`. The forbidden_view_config constructor accepts most of the same arguments as the constructor of :class:`pyramid.view.view_config`. It can be used @@ -468,13 +473,15 @@ class exception_view_config(object): .. versionadded:: 1.8 An analogue of :class:`pyramid.view.view_config` which registers an - exception view. + :term:`exception view` using + :meth:`pyramid.config.Configurator.add_exception_view`. - The exception_view_config constructor requires an exception context, and - additionally accepts most of the same arguments as the constructor of + The ``exception_view_config`` constructor requires an exception context, + and additionally accepts most of the same arguments as the constructor of :class:`pyramid.view.view_config`. It can be used in the same places, - and behaves in largely the same way, except it always registers an exception - view instead of a 'normal' view. + and behaves in largely the same way, except it always registers an + exception view instead of a 'normal' view that dispatches on the request + :term:`context`. Example: @@ -483,17 +490,23 @@ class exception_view_config(object): from pyramid.view import exception_view_config from pyramid.response import Response - @exception_view_config(context=ValueError, renderer='json') - def error_view(context, request): - return {'error': str(context)} + @exception_view_config(ValueError, renderer='json') + def error_view(request): + return {'error': str(request.exception)} All arguments passed to this function have the same meaning as :meth:`pyramid.view.view_config` and each predicate argument restricts the set of circumstances under which this exception view will be invoked. + """ venusian = venusian - def __init__(self, **settings): + def __init__(self, *args, **settings): + if 'context' not in settings and len(args) > 0: + exception, args = args[0], args[1:] + settings['context'] = exception + if len(args) > 0: + raise ConfigurationError('unknown positional arguments') self.__dict__.update(settings) def __call__(self, wrapped): diff --git a/pyramid/viewderivers.py b/pyramid/viewderivers.py index 5d138a02a..513ddf022 100644 --- a/pyramid/viewderivers.py +++ b/pyramid/viewderivers.py @@ -286,18 +286,16 @@ def _secured_view(view, info): authn_policy = info.registry.queryUtility(IAuthenticationPolicy) authz_policy = info.registry.queryUtility(IAuthorizationPolicy) + # no-op on exception-only views without an explicit permission + if explicit_val is None and info.exception_only: + return view + if authn_policy and authz_policy and (permission is not None): - def _permitted(context, request): + def permitted(context, request): principals = authn_policy.effective_principals(request) return authz_policy.permits(context, principals, permission) - def _secured_view(context, request): - if ( - getattr(request, 'exception', None) is not None and - explicit_val is None - ): - return view(context, request) - - result = _permitted(context, request) + def secured_view(context, request): + result = permitted(context, request) if result: return view(context, request) view_name = getattr(view, '__name__', view) @@ -305,10 +303,10 @@ def _secured_view(view, info): request, 'authdebug_message', 'Unauthorized: %s failed permission check' % view_name) raise HTTPForbidden(msg, result=result) - _secured_view.__call_permissive__ = view - _secured_view.__permitted__ = _permitted - _secured_view.__permission__ = permission - wrapped_view = _secured_view + wrapped_view = secured_view + wrapped_view.__call_permissive__ = view + wrapped_view.__permitted__ = permitted + wrapped_view.__permission__ = permission return wrapped_view @@ -321,14 +319,13 @@ def _authdebug_view(view, info): authn_policy = info.registry.queryUtility(IAuthenticationPolicy) authz_policy = info.registry.queryUtility(IAuthorizationPolicy) logger = info.registry.queryUtility(IDebugLogger) - if settings and settings.get('debug_authorization', False): - def _authdebug_view(context, request): - if ( - getattr(request, 'exception', None) is not None and - explicit_val is None - ): - return view(context, request) + # no-op on exception-only views without an explicit permission + if explicit_val is None and info.exception_only: + return view + + if settings and settings.get('debug_authorization', False): + def authdebug_view(context, request): view_name = getattr(request, 'view_name', None) if authn_policy and authz_policy: @@ -352,8 +349,7 @@ def _authdebug_view(view, info): if request is not None: request.authdebug_message = msg return view(context, request) - - wrapped_view = _authdebug_view + wrapped_view = authdebug_view return wrapped_view @@ -490,23 +486,22 @@ def csrf_view(view, info): token = defaults.token header = defaults.header safe_methods = defaults.safe_methods + enabled = ( explicit_val is True or - (explicit_val is not False and default_val) + # fallback to the default val if not explicitly enabled + # but only if the view is not an exception view + ( + explicit_val is not False and default_val and + not info.exception_only + ) ) # disable if both header and token are disabled enabled = enabled and (token or header) wrapped_view = view if enabled: def csrf_view(context, request): - if ( - request.method not in safe_methods and - ( - # skip exception views unless value is explicitly defined - getattr(request, 'exception', None) is None or - explicit_val is not None - ) - ): + if request.method not in safe_methods: check_csrf_origin(request, raises=True) check_csrf_token(request, token, header, raises=True) return view(context, request) -- cgit v1.2.3