diff options
| -rw-r--r-- | CHANGES.rst | 17 | ||||
| -rw-r--r-- | docs/glossary.rst | 6 | ||||
| -rw-r--r-- | docs/narr/extconfig.rst | 3 | ||||
| -rw-r--r-- | docs/narr/viewconfig.rst | 148 | ||||
| -rw-r--r-- | pyramid/config/__init__.py | 1 | ||||
| -rw-r--r-- | pyramid/config/routes.py | 38 | ||||
| -rw-r--r-- | pyramid/config/views.py | 234 | ||||
| -rw-r--r-- | pyramid/interfaces.py | 7 | ||||
| -rw-r--r-- | pyramid/predicates.py | 8 | ||||
| -rw-r--r-- | pyramid/testing.py | 2 | ||||
| -rw-r--r-- | pyramid/tests/test_config/test_routes.py | 30 | ||||
| -rw-r--r-- | pyramid/tests/test_config/test_views.py | 42 | ||||
| -rw-r--r-- | pyramid/tests/test_util.py | 60 | ||||
| -rw-r--r-- | pyramid/util.py | 77 | ||||
| -rw-r--r-- | tox.ini | 3 |
15 files changed, 547 insertions, 129 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index 926584de0..c3049a368 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -57,6 +57,16 @@ Features - Add support for Python 3.7. Add testing on Python 3.8 with allowed failures. See https://github.com/Pylons/pyramid/pull/3333 +- Added the ``pyramid.config.Configurator.add_accept_view_order`` directive, + allowing users to specify media type preferences in ambiguous situations + such as when several views match. A default ordering is defined for media + types that prefers human-readable html/text responses over JSON. + See https://github.com/Pylons/pyramid/pull/3326 + +- Support a list of media types in the ``accept`` predicate used in + ``pyramid.config.Configurator.add_route``. + See https://github.com/Pylons/pyramid/pull/3326 + - Added ``pyramid.session.JSONSerializer``. See "Upcoming Changes to ISession in Pyramid 2.0" in the "Sessions" chapter of the documentation for more information about this feature. @@ -131,6 +141,13 @@ Backward Incompatibilities of previous ``pyramid.httpexceptions.HTTPFound``. See https://github.com/Pylons/pyramid/pull/3328 +- Accept-handling has undergone work to get rid of undefined behaviors and + runtime exceptions. As part of this effort, it is now a hard error to pass + any media ranges to the ``accept`` predicate on routes and views. + Previously, depending on the version of WebOb, this would error on certain + requests or it would work in undefined ways. + https://github.com/Pylons/pyramid/pull/3326 + Documentation Changes --------------------- diff --git a/docs/glossary.rst b/docs/glossary.rst index ec4cffef2..7a9c91668 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -1209,3 +1209,9 @@ Glossary Alembic `Alembic <http://alembic.zzzcomputing.com/en/latest/>`_ is a lightweight database migration tool for usage with the SQLAlchemy Database Toolkit for Python. + + media type + A label representing the type of some content. + A media type is a nested structure containing a top-level type and a subtype. + Optionally, a media type can also contain parameters specific to the type. + See :rfc:`6838` for more information about media types. diff --git a/docs/narr/extconfig.rst b/docs/narr/extconfig.rst index 61bd7a05f..4c6c8b70b 100644 --- a/docs/narr/extconfig.rst +++ b/docs/narr/extconfig.rst @@ -255,11 +255,12 @@ Pre-defined Phases :const:`pyramid.config.PHASE1_CONFIG` +- :meth:`pyramid.config.Configurator.add_accept_view_order` - :meth:`pyramid.config.Configurator.add_renderer` - :meth:`pyramid.config.Configurator.add_route_predicate` - :meth:`pyramid.config.Configurator.add_subscriber_predicate` -- :meth:`pyramid.config.Configurator.add_view_predicate` - :meth:`pyramid.config.Configurator.add_view_deriver` +- :meth:`pyramid.config.Configurator.add_view_predicate` - :meth:`pyramid.config.Configurator.override_asset` - :meth:`pyramid.config.Configurator.set_authorization_policy` - :meth:`pyramid.config.Configurator.set_default_csrf_options` diff --git a/docs/narr/viewconfig.rst b/docs/narr/viewconfig.rst index c463d297e..7e6617b78 100644 --- a/docs/narr/viewconfig.rst +++ b/docs/narr/viewconfig.rst @@ -285,6 +285,17 @@ Non-Predicate Arguments are just developing stock Pyramid applications. Pay no attention to the man behind the curtain. +``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 + Predicate Arguments +++++++++++++++++++ @@ -317,17 +328,6 @@ configured view. 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. @@ -344,6 +344,20 @@ configured view. request/context pair found via :term:`resource location` does not indicate it matched any configured route. +``accept`` + A :term:`media type` that will be matched against the ``Accept`` HTTP request header. + If this value is specified, it must be a specific media type, such as ``text/html``. + If the media type is acceptable by the ``Accept`` header of the request, or if the ``Accept`` header isn't set at all in the request, this predicate will match. + If this does not match the ``Accept`` header of the request, view matching continues. + + If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is not taken into consideration when deciding whether or not to invoke the associated view callable. + + See :ref:`accept_content_negotiation` for more information. + + .. versionchanged:: 1.10 + Media ranges such as ``text/*`` will now raise :class:`pyramid.exceptions.ConfigurationError`. + Previously these values had undefined behavior based on the version of WebOb being used and was never fully supported. + ``request_type`` This value should be an :term:`interface` that the :term:`request` must provide in order for this view to be found and called. @@ -424,19 +438,6 @@ configured view. taken into consideration when deciding whether or not to invoke the associated view callable. -``accept`` - The value of this argument represents a match query for one or more mimetypes - in the ``Accept`` HTTP request header. If this value is specified, it must - be in one of the following forms: a mimetype match token in the form - ``text/plain``, a wildcard mimetype match token in the form ``text/*``, or a - match-all wildcard mimetype match token in the form ``*/*``. If any of the - forms matches the ``Accept`` header of the request, this predicate will be - true. - - If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is not taken - into consideration when deciding whether or not to invoke the associated view - callable. - ``header`` This value represents an HTTP header name or a header name/value pair. @@ -1026,6 +1027,105 @@ See :ref:`environment_chapter` for more information about how, and where to set these values. .. index:: + single: Accept + single: Accept content negotiation + +.. _accept_content_negotiation: + +Accept Header Content Negotiation +--------------------------------- + +The ``accept`` argument to :meth:`pyramid.config.Configurator.add_view` can be used to control :term:`view lookup` by dispatching to different views based on the HTTP ``Accept`` request header. +Consider the example below in which there are three defined views. +Each view uses the ``Accept`` header to trigger an appropriate response renderer. + +.. code-block:: python + + from pyramid.httpexceptions import HTTPNotAcceptable + from pyramid.view import view_config + + @view_config(accept='application/json', renderer='json') + @view_config(accept='text/html', renderer='templates/hello.jinja2') + def myview(request): + return { + 'name': request.GET.get('name', 'bob'), + } + + @view_config() + def myview_unacceptable(request): + raise HTTPNotAcceptable + +The appropriate view is selected here when the client specifies an unambiguous header such as ``Accept: text/*`` or ``Accept: application/json``. +Similarly, if the client specifies a media type that no view is registered to handle, such as ``Accept: text/plain``, it will fall through to ``myview_unacceptable`` and raise ``406 Not Acceptable``. +There are a few cases in which the client may specify ambiguous constraints: + +- ``Accept: */*``. +- More than one acceptable media type with the same quality. +- A missing ``Accept`` header. +- An invalid ``Accept`` header. + +In these cases the preferred view is not clearly defined (see :rfc:`7231#section-5.3.2`) and :app:`Pyramid` will select one semi-randomly. +This can be controlled by telling :app:`Pyramid` what the preferred relative ordering is between various media types by using :meth:`pyramid.config.Configurator.add_accept_view_order`. +For example: + +.. code-block:: python + + from pyramid.config import Configurator + + def main(global_config, **settings): + config = Configurator(settings=settings) + config.add_accept_view_order('text/html') + config.add_accept_view_order( + 'application/json', + weighs_more_than='text/html', + ) + config.scan() + return config.make_wsgi_app() + +In this case, the ``application/json`` view should always be selected in cases where it is otherwise ambiguous. + +.. index:: + single: default accept ordering + +.. _default_accept_ordering: + +Default Accept Ordering ++++++++++++++++++++++++ + +:app:`Pyramid` will always sort multiple views with the same ``(name, context, route_name)`` first by the specificity of the ``accept`` offer. +This means that ``text/plain`` will always be offered before ``text/*``. +Similarly ``text/plain;charset=utf8`` will always be offered before ``text/plain``. +The following order is always preserved between the following offers (more preferred to less preferred): + +- ``type/subtype;params`` +- ``type/subtype`` +- ``type/*`` +- ``*/*`` + +Within each of these levels of specificity, the ordering is ambiguous and may be controlled using :meth:`pyramid.config.Configurator.add_accept_view_order`. For example, to sort ``text/plain`` higher than ``text/html`` and to prefer a ``charset=utf8`` versus a ``charset=latin-1`` within the ``text/plain`` media type: + +.. code-block:: python + + config.add_accept_view_order('text/plain', weighs_more_than='text/html') + config.add_accept_view_order('text/plain;charset=utf8', weighs_more_than='text/plain;charset=latin-1') + +It is an error to try and sort accept headers across levels of specificity. You can only sort a ``type/subtype`` against another ``type/subtype``, not against a ``type/*``. That ordering is a hard requirement. + +By default, :app:`Pyramid` defines a very simple priority ordering for views that prefers human-readable responses over JSON: + +- ``text/html`` +- ``application/xhtml+xml`` +- ``application/xml`` +- ``text/xml`` +- ``text/plain`` +- ``application/json`` + +API clients tend to be able to specify their desired headers with more control than web browsers, and can specify the correct ``Accept`` value, if necessary. +Therefore, the motivation for this ordering is to optimize for readability. +Media types that are not listed above are ordered randomly during :term:`view lookup` between otherwise-similar views. +The defaults can be overridden using :meth:`pyramid.config.Configurator.add_accept_view_order` as described above. + +.. index:: single: HTTP caching .. _influencing_http_caching: diff --git a/pyramid/config/__init__.py b/pyramid/config/__init__.py index f4fcf413e..2f4e133f0 100644 --- a/pyramid/config/__init__.py +++ b/pyramid/config/__init__.py @@ -394,6 +394,7 @@ class Configurator( self.add_default_response_adapters() self.add_default_renderers() + self.add_default_accept_view_order() self.add_default_view_predicates() self.add_default_view_derivers() self.add_default_route_predicates() diff --git a/pyramid/config/routes.py b/pyramid/config/routes.py index 904c7bd4e..01917537d 100644 --- a/pyramid/config/routes.py +++ b/pyramid/config/routes.py @@ -13,7 +13,10 @@ from pyramid.exceptions import ConfigurationError from pyramid.request import route_request_iface from pyramid.urldispatch import RoutesMapper -from pyramid.util import as_sorted_tuple +from pyramid.util import ( + as_sorted_tuple, + is_nonstr_iter, +) import pyramid.predicates @@ -139,18 +142,6 @@ class RoutesConfiguratorMixin(object): .. versionadded:: 1.1 - accept - - This value represents a match query for one or more mimetypes in the - ``Accept`` HTTP request header. If this value is specified, it must - be in one of the following forms: a mimetype match token in the form - ``text/plain``, a wildcard mimetype match token in the form - ``text/*`` or a match-all wildcard mimetype match token in the form - ``*/*``. If any of the forms matches the ``Accept`` header of the - request, or if the ``Accept`` header isn't set at all in the request, - this will match the current route. If this does not match the - ``Accept`` header of the request, route matching continues. - Predicate Arguments pattern @@ -233,6 +224,20 @@ class RoutesConfiguratorMixin(object): case of the header name is not significant. If this predicate returns ``False``, route matching continues. + accept + + A :term:`media type` that will be matched against the ``Accept`` + HTTP request header. This value may be a specific media type such + as ``text/html``, or a range like ``text/*``, or a list of the same. + If the media type is acceptable by the ``Accept`` header of the + request, or if the ``Accept`` header isn't set at all in the + request, this predicate will match. If this does not match the + ``Accept`` header of the request, route matching continues. + + If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is + not taken into consideration when deciding whether or not to select + the route. + effective_principals If specified, this value should be a :term:`principal` identifier or @@ -289,6 +294,13 @@ class RoutesConfiguratorMixin(object): DeprecationWarning, stacklevel=3 ) + + if accept is not None: + if not is_nonstr_iter(accept): + accept = [accept] + + accept = [accept_option.lower() for accept_option in accept] + # these are route predicates; if they do not match, the next route # in the routelist will be tried if request_method is not None: diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 5d46de276..e40a851ff 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -5,6 +5,7 @@ import operator import os import warnings +from webob.acceptparse import Accept from zope.interface import ( Interface, implementedBy, @@ -13,6 +14,7 @@ from zope.interface import ( from zope.interface.interfaces import IInterface from pyramid.interfaces import ( + IAcceptOrder, IExceptionViewClassifier, IException, IMultiView, @@ -66,6 +68,7 @@ from pyramid.view import AppendSlashNotFoundViewFactory from pyramid.util import ( as_sorted_tuple, + sort_accept_offers, TopologicalSorter, ) @@ -115,14 +118,14 @@ class MultiView(object): view = self.match(context, request) return view.__discriminator__(context, request) - def add(self, view, order, accept=None, phash=None): + def add(self, view, order, phash=None, accept=None, accept_order=None): if phash is not None: for i, (s, v, h) in enumerate(list(self.views)): if phash == h: self.views[i] = (order, view, phash) return - if accept is None or '*' in accept: + if accept is None: self.views.append((order, view, phash)) self.views.sort(key=operator.itemgetter(0)) else: @@ -134,21 +137,18 @@ class MultiView(object): else: subset.append((order, view, phash)) subset.sort(key=operator.itemgetter(0)) + # dedupe accepts and sort appropriately accepts = set(self.accepts) accepts.add(accept) - self.accepts = list(accepts) # dedupe + if accept_order: + accept_order = accept_order.sorted() + self.accepts = sort_accept_offers(accepts, accept_order) def get_views(self, request): if self.accepts and hasattr(request, 'accept'): - accepts = self.accepts[:] views = [] - while accepts: - match = request.accept.best_match(accepts) - if match is None: - break - subset = self.media_views[match] - views.extend(subset) - accepts.remove(match) + for offer, _ in request.accept.acceptable_offers(self.accepts): + views.extend(self.media_views[offer]) views.extend(self.views) return views return self.views @@ -242,6 +242,14 @@ def viewdefaults(wrapped): return wrapped(self, *arg, **defaults) return functools.wraps(wrapped)(wrapper) +def combine_decorators(*decorators): + def decorated(view_callable): + # reversed() allows a more natural ordering in the api + for decorator in reversed(decorators): + view_callable = decorator(view_callable) + return view_callable + return decorated + class ViewsConfiguratorMixin(object): @viewdefaults @action_method @@ -533,17 +541,17 @@ class ViewsConfiguratorMixin(object): very useful for 'civilians' who are just developing stock Pyramid applications. Pay no attention to the man behind the curtain. - accept + exception_only - This value represents a match query for one or more mimetypes in the - ``Accept`` HTTP request header. If this value is specified, it must - be in one of the following forms: a mimetype match token in the form - ``text/plain``, a wildcard mimetype match token in the form - ``text/*`` or a match-all wildcard mimetype match token in the form - ``*/*``. If any of the forms matches the ``Accept`` header of the - request, or if the ``Accept`` header isn't set at all in the request, - this will match the current view. If this does not match the - ``Accept`` header of the request, view matching continues. + .. 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. Predicate Arguments @@ -566,18 +574,6 @@ class ViewsConfiguratorMixin(object): 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 This value must match the ``name`` of a :term:`route @@ -677,6 +673,22 @@ class ViewsConfiguratorMixin(object): represents a header name or a header name/value pair, the case of the header name is not significant. + accept + + A :term:`media type` that will be matched against the ``Accept`` + HTTP request header. This value may be a specific media type such + as ``text/html``, or a range like ``text/*``. If the media type is + acceptable by the ``Accept`` header of the request, or if the + ``Accept`` header isn't set at all in the request, this predicate + will match. If this does not match the ``Accept`` header of the + request, view matching continues. + + If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is + not taken into consideration when deciding whether or not to invoke + the associated view callable. + + See :ref:`accept_content_negotiation` for more information. + path_info This value represents a regular expression pattern that will @@ -804,22 +816,21 @@ class ViewsConfiguratorMixin(object): stacklevel=4, ) + if accept is not None: + if is_nonstr_iter(accept): + raise ConfigurationError( + 'A list is not supported in the "accept" view predicate.', + ) + accept = accept.lower() + view = self.maybe_dotted(view) context = self.maybe_dotted(context) for_ = self.maybe_dotted(for_) containment = self.maybe_dotted(containment) mapper = self.maybe_dotted(mapper) - def combine(*decorators): - def decorated(view_callable): - # reversed() allows a more natural ordering in the api - for decorator in reversed(decorators): - view_callable = decorator(view_callable) - return view_callable - return decorated - if is_nonstr_iter(decorator): - decorator = combine(*map(self.maybe_dotted, decorator)) + decorator = combine_decorators(*map(self.maybe_dotted, decorator)) else: decorator = self.maybe_dotted(decorator) @@ -857,9 +868,6 @@ class ViewsConfiguratorMixin(object): name=renderer, package=self.package, registry=self.registry) - if accept is not None: - accept = accept.lower() - introspectables = [] ovals = view_options.copy() ovals.update(dict( @@ -1062,7 +1070,17 @@ class ViewsConfiguratorMixin(object): if old_view is not None: break - def regclosure(): + old_phash = getattr(old_view, '__phash__', DEFAULT_PHASH) + is_multiview = IMultiView.providedBy(old_view) + want_multiview = ( + is_multiview + # no component was yet registered for exactly this triad + # or only one was registered but with the same phash, meaning + # that this view is an override + or (old_view is not None and old_phash != phash) + ) + + if not want_multiview: if hasattr(derived_view, '__call_permissive__'): view_iface = ISecuredView else: @@ -1074,21 +1092,6 @@ class ViewsConfiguratorMixin(object): name ) - is_multiview = IMultiView.providedBy(old_view) - old_phash = getattr(old_view, '__phash__', DEFAULT_PHASH) - - if old_view is None: - # - No component was yet registered for any of our I*View - # interfaces exactly; this is the first view for this - # triad. - regclosure() - - elif (not is_multiview) and (old_phash == phash): - # - A single view component was previously registered with - # the same predicate hash as this view; this registration - # is therefore an override. - regclosure() - else: # - A view or multiview was already registered for this # triad, and the new view is not an override. @@ -1104,8 +1107,11 @@ class ViewsConfiguratorMixin(object): multiview = MultiView(name) old_accept = getattr(old_view, '__accept__', None) old_order = getattr(old_view, '__order__', MAX_ORDER) - multiview.add(old_view, old_order, old_accept, old_phash) - multiview.add(derived_view, order, accept, phash) + # don't bother passing accept_order here as we know we're + # adding another one right after which will re-sort + multiview.add(old_view, old_order, old_phash, old_accept) + accept_order = self.registry.queryUtility(IAcceptOrder) + multiview.add(derived_view, order, phash, accept, accept_order) for view_type in (IView, ISecuredView): # unregister any existing views self.registry.adapters.unregister( @@ -1222,6 +1228,108 @@ class ViewsConfiguratorMixin(object): ): self.add_view_predicate(name, factory) + def add_default_accept_view_order(self): + for accept in ( + 'text/html', + 'application/xhtml+xml', + 'application/xml', + 'text/xml', + 'text/plain', + 'application/json', + ): + self.add_accept_view_order(accept) + + @action_method + def add_accept_view_order( + self, + value, + weighs_more_than=None, + weighs_less_than=None, + ): + """ + Specify an ordering preference for the ``accept`` view option used + during :term:`view lookup`. + + By default, if two views have different ``accept`` options and a + request specifies ``Accept: */*`` or omits the header entirely then + it is random which view will be selected. This method provides a way + to specify a server-side, relative ordering between accept media types. + + ``value`` should be a :term:`media type` as specified by + :rfc:`7231#section-5.3.2`. For example, ``text/plain;charset=utf8``, + ``application/json`` or ``text/html``. + + ``weighs_more_than`` and ``weighs_less_than`` control the ordering + of media types. Each value may be a string or a list of strings. + + See :ref:`accept_content_negotiation` for more information. + + .. versionadded:: 1.10 + + """ + if value == '*/*': + raise ConfigurationError( + 'cannot specify an ordering for an offer of */*') + + def normalize_type(type): + return type.lower() + + def check_type(than): + than_type, than_subtype, than_params = Accept.parse_offer(than) + if ( + # text/* vs text/plain + (offer_subtype == '*') ^ (than_subtype == '*') + # text/plain vs text/html;charset=utf8 + or (bool(offer_params) ^ bool(than_params)) + ): + raise ConfigurationError( + 'cannot compare across media range specificity levels') + # text/plain;charset=utf8 vs text/html;charset=utf8 + if offer_params and ( + offer_subtype != than_subtype or offer_type != than_type + ): + raise ConfigurationError( + 'cannot compare params across media types') + + value = normalize_type(value) + offer_type, offer_subtype, offer_params = Accept.parse_offer(value) + + if weighs_more_than: + if not is_nonstr_iter(weighs_more_than): + weighs_more_than = [weighs_more_than] + weighs_more_than = [normalize_type(w) for w in weighs_more_than] + for than in weighs_more_than: + check_type(than) + + if weighs_less_than: + if not is_nonstr_iter(weighs_less_than): + weighs_less_than = [weighs_less_than] + weighs_less_than = [normalize_type(w) for w in weighs_less_than] + for than in weighs_less_than: + check_type(than) + + discriminator = ('accept view order', value) + intr = self.introspectable( + 'accept view order', + value, + value, + 'accept view order') + intr['value'] = value + intr['weighs_more_than'] = weighs_more_than + intr['weighs_less_than'] = weighs_less_than + def register(): + sorter = self.registry.queryUtility(IAcceptOrder) + if sorter is None: + sorter = TopologicalSorter() + self.registry.registerUtility(sorter, IAcceptOrder) + sorter.add( + value, value, + after=weighs_more_than, + before=weighs_less_than, + ) + self.action(discriminator, register, introspectables=(intr,), + order=PHASE1_CONFIG) # must be registered before add_view + @action_method def add_view_deriver(self, deriver, name=None, under=None, over=None): """ diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index 551ab701e..4df5593f8 100644 --- a/pyramid/interfaces.py +++ b/pyramid/interfaces.py @@ -586,6 +586,13 @@ class IRouteRequest(Interface): """ *internal only* interface used as in a utility lookup to find route-specific interfaces. Not an API.""" +class IAcceptOrder(Interface): + """ + Marker interface for a list of accept headers with the most important + first. + + """ + class IStaticURLInfo(Interface): """ A policy for generating URLs to static assets """ def add(config, name, spec, **extra): diff --git a/pyramid/predicates.py b/pyramid/predicates.py index 5e54badff..5bd98fdf2 100644 --- a/pyramid/predicates.py +++ b/pyramid/predicates.py @@ -131,15 +131,17 @@ class HeaderPredicate(object): class AcceptPredicate(object): def __init__(self, val, config): - self.val = val + if not is_nonstr_iter(val): + val = (val,) + self.values = val def text(self): - return 'accept = %s' % (self.val,) + return 'accept = %s' % (', '.join(self.values),) phash = text def __call__(self, context, request): - return self.val in request.accept + return bool(request.accept.acceptable_offers(self.values)) class ContainmentPredicate(object): def __init__(self, val, config): diff --git a/pyramid/testing.py b/pyramid/testing.py index 7ff4c2f73..4986c0e27 100644 --- a/pyramid/testing.py +++ b/pyramid/testing.py @@ -474,7 +474,9 @@ def setUp(registry=None, request=None, hook_zca=True, autocommit=True, # someone may be passing us an esoteric "dummy" registry, and # the below won't succeed if it doesn't have a registerUtility # method. + config.add_default_response_adapters() config.add_default_renderers() + config.add_default_accept_view_order() config.add_default_view_predicates() config.add_default_view_derivers() config.add_default_route_predicates() diff --git a/pyramid/tests/test_config/test_routes.py b/pyramid/tests/test_config/test_routes.py index 1d2530c02..afae4db33 100644 --- a/pyramid/tests/test_config/test_routes.py +++ b/pyramid/tests/test_config/test_routes.py @@ -182,10 +182,25 @@ class RoutesConfiguratorMixinTests(unittest.TestCase): route = self._assertRoute(config, 'name', 'path', 1) predicate = route.predicates[0] request = self._makeRequest(config) - request.accept = ['text/xml'] + request.accept = DummyAccept('text/xml') self.assertEqual(predicate(None, request), True) request = self._makeRequest(config) - request.accept = ['text/html'] + request.accept = DummyAccept('text/html') + self.assertEqual(predicate(None, request), False) + + def test_add_route_with_accept_list(self): + config = self._makeOne(autocommit=True) + config.add_route('name', 'path', accept=['text/xml', 'text/plain']) + route = self._assertRoute(config, 'name', 'path', 1) + predicate = route.predicates[0] + request = self._makeRequest(config) + request.accept = DummyAccept('text/xml') + self.assertEqual(predicate(None, request), True) + request = self._makeRequest(config) + request.accept = DummyAccept('text/plain') + self.assertEqual(predicate(None, request), True) + request = self._makeRequest(config) + request.accept = DummyAccept('text/html') self.assertEqual(predicate(None, request), False) def test_add_route_no_pattern_with_path(self): @@ -253,3 +268,14 @@ class DummyRequest: self.environ = environ self.params = {} self.cookies = {} + +class DummyAccept(object): + def __init__(self, *matches): + self.matches = list(matches) + + def acceptable_offers(self, offers): + results = [] + for match in self.matches: + if match in offers: + results.append((match, 1.0)) + return results diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 1c99d2ac5..db15a39fb 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -842,7 +842,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view2, renderer=null_renderer) wrapper = self._getViewCallable(config) self.assertTrue(IMultiView.providedBy(wrapper)) - self.assertEqual([x[:2] for x in wrapper.views], [(view2, None)]) + self.assertEqual([(x[0], x[2]) for x in wrapper.views], [(view2, None)]) self.assertEqual(wrapper(None, None), 'OK1') def test_add_view_exc_multiview_replaces_multiviews(self): @@ -869,13 +869,13 @@ class TestViewsConfigurationMixin(unittest.TestCase): 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([(x[0], 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([(x[0], 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): @@ -908,7 +908,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): 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([(x[0], 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): @@ -1465,7 +1465,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view, accept='text/xml', renderer=null_renderer) wrapper = self._getViewCallable(config) request = self._makeRequest(config) - request.accept = ['text/xml'] + request.accept = DummyAccept('text/xml') self.assertEqual(wrapper(None, request), 'OK') def test_add_view_with_accept_nomatch(self): @@ -1474,7 +1474,7 @@ class TestViewsConfigurationMixin(unittest.TestCase): config.add_view(view=view, accept='text/xml') wrapper = self._getViewCallable(config) request = self._makeRequest(config) - request.accept = ['text/html'] + request.accept = DummyAccept('text/html') self._assertNotFound(wrapper, None, request) def test_add_view_with_containment_true(self): @@ -2499,19 +2499,17 @@ class TestMultiView(unittest.TestCase): self.assertEqual(mv.views, [(100, 'view', None)]) mv.add('view2', 99) self.assertEqual(mv.views, [(99, 'view2', None), (100, 'view', None)]) - mv.add('view3', 100, 'text/html') + mv.add('view3', 100, accept='text/html') self.assertEqual(mv.media_views['text/html'], [(100, 'view3', None)]) - mv.add('view4', 99, 'text/html', 'abc') + mv.add('view4', 99, 'abc', accept='text/html') self.assertEqual(mv.media_views['text/html'], [(99, 'view4', 'abc'), (100, 'view3', None)]) - mv.add('view5', 100, 'text/xml') + mv.add('view5', 100, accept='text/xml') self.assertEqual(mv.media_views['text/xml'], [(100, 'view5', None)]) self.assertEqual(set(mv.accepts), set(['text/xml', 'text/html'])) self.assertEqual(mv.views, [(99, 'view2', None), (100, 'view', None)]) - mv.add('view6', 98, 'text/*') - self.assertEqual(mv.views, [(98, 'view6', None), - (99, 'view2', None), - (100, 'view', None)]) + mv.add('view6', 98, accept='text/*') + self.assertEqual(mv.media_views['text/*'], [(98, 'view6', None)]) def test_add_with_phash(self): mv = self._makeOne() @@ -3440,14 +3438,12 @@ class DummyAccept(object): def __init__(self, *matches): self.matches = list(matches) - def best_match(self, offered): - if self.matches: - for match in self.matches: - if match in offered: - self.matches.remove(match) - return match - def __contains__(self, val): - return val in self.matches + def acceptable_offers(self, offers): + results = [] + for match in self.matches: + if match in offers: + results.append((match, 1.0)) + return results class DummyConfig: def __init__(self): @@ -3475,8 +3471,8 @@ class DummyMultiView: def __init__(self): self.views = [] self.name = 'name' - def add(self, view, order, accept=None, phash=None): - self.views.append((view, accept, phash)) + def add(self, view, order, phash=None, accept=None, accept_order=None): + self.views.append((view, phash, accept, accept_order)) def __call__(self, context, request): return 'OK1' def __permitted__(self, context, request): diff --git a/pyramid/tests/test_util.py b/pyramid/tests/test_util.py index a76cd2017..907ca7351 100644 --- a/pyramid/tests/test_util.py +++ b/pyramid/tests/test_util.py @@ -1109,3 +1109,63 @@ class TestSimpleSerializer(unittest.TestCase): def test_dumps(self): inst = self._makeOne() self.assertEqual(inst.dumps('abc'), bytes_('abc')) + + +class Test_sort_accept_offers(unittest.TestCase): + def _callFUT(self, offers, order=None): + from pyramid.util import sort_accept_offers + return sort_accept_offers(offers, order) + + def test_default_specificities(self): + result = self._callFUT(['*/*', 'text/*', 'text/html', 'text/html;charset=utf8']) + self.assertEqual(result, [ + 'text/html;charset=utf8', 'text/html', 'text/*', '*/*', + ]) + + def test_wildcard_type_order(self): + result = self._callFUT( + ['*/*', 'text/*', 'image/*'], + ['image/*', 'text/*'], + ) + self.assertEqual(result, ['image/*', 'text/*', '*/*']) + + def test_specific_type_order(self): + result = self._callFUT( + ['text/html', 'application/json', 'text/html;charset=utf8', 'text/plain'], + ['application/json', 'text/html'], + ) + self.assertEqual(result, [ + 'application/json', 'text/html;charset=utf8', 'text/html', 'text/plain', + ]) + + def test_params_order(self): + result = self._callFUT( + ['text/html;charset=utf8', 'text/html;charset=latin1', 'text/html;foo=bar'], + ['text/html;charset=latin1', 'text/html;charset=utf8'], + ) + self.assertEqual(result, [ + 'text/html;charset=latin1', 'text/html;charset=utf8', 'text/html;foo=bar', + ]) + + def test_params_inherit_type_prefs(self): + result = self._callFUT( + ['text/html;charset=utf8', 'text/plain;charset=latin1'], + ['text/plain', 'text/html'], + ) + self.assertEqual(result, ['text/plain;charset=latin1', 'text/html;charset=utf8']) + + def test_params_inherit_wildcard_prefs(self): + result = self._callFUT( + ['image/png;progressive=1', 'text/html;charset=utf8'], + ['text/*', 'image/*'], + ) + self.assertEqual(result, ['text/html;charset=utf8', 'image/png;progressive=1']) + + def test_type_overrides_wildcard_prefs(self): + result = self._callFUT( + ['text/html;charset=utf8', 'image/png', 'foo/bar', 'text/bar'], + ['foo/*', 'text/*', 'image/*', 'image/png', 'text/html'], + ) + self.assertEqual(result, [ + 'image/png', 'text/html;charset=utf8', 'foo/bar', 'text/bar', + ]) diff --git a/pyramid/util.py b/pyramid/util.py index 6655455bf..708931eea 100644 --- a/pyramid/util.py +++ b/pyramid/util.py @@ -1,3 +1,5 @@ +from collections import defaultdict +from collections import namedtuple from contextlib import contextmanager import functools try: @@ -7,6 +9,7 @@ except ImportError: # pragma: no cover compare_digest = None import inspect import weakref +from webob.acceptparse import Accept from pyramid.exceptions import ( ConfigurationError, @@ -649,3 +652,77 @@ class SimpleSerializer(object): def dumps(self, appstruct): return bytes_(appstruct) + +def sort_accept_offers(offers, order=None): + """ + Sort a list of offers by specificity and preference. + + Supported offers are of the following forms, ordered by specificity + (higher to lower): + + - ``type/subtype;params`` and ``type/subtype`` + - ``type/*`` + - ``*/*`` + + :param offers: A list of offers to be sorted. + :param order: A weighted list of offers where items closer to the start of + the list will be a preferred over items closer to the end. + :return: A list of offers sorted first by specificity (higher to lower) + then by ``order``. + + """ + if order is None: + order = [] + + max_weight = len(offers) + + def find_order_index(value, default=None): + return next((i for i, x in enumerate(order) if x == value), default) + + def offer_sort_key(value): + """ + (category, type_weight, params_weight) + + category: + 1 - foo/bar and foo/bar;params + 2 - foo/* + 3 - */* + + type_weight: + if category 1 & 2: + - index of type/* in order list + - ``max_weight`` if no match is found + + - index of type/subtype in order list + - index of type/* in order list + ``max_weight`` + - ``max_weight * 2`` if no match is found + + params_weight: + - index of specific ``type/subtype;params`` in order list + - ``max_weight`` if not found + - ``max_weight + 1`` if no params at all + + """ + parsed = Accept.parse_offer(value) + + if value == '*/*': + return (3, 0, 0) + + elif parsed.subtype == '*': + type_w = find_order_index(value, max_weight) + return (2, type_w, 0) + + type_w = find_order_index(parsed.type + '/' + parsed.subtype, None) + if type_w is None: + type_w = max_weight + find_order_index( + parsed.type + '/*', max_weight) + + if parsed.params: + param_w = find_order_index(value, max_weight) + + else: + param_w = max_weight + 1 + + return (1, type_w, param_w) + + return sorted(offers, key=offer_sort_key) @@ -25,6 +25,9 @@ commands = extras = testing +deps = + -egit+https://github.com/mmerickel/webob.git@accept-parse-offer#egg=webob + [testenv:py27-scaffolds] basepython = python2.7 commands = |
