summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES.rst17
-rw-r--r--docs/glossary.rst6
-rw-r--r--docs/narr/extconfig.rst3
-rw-r--r--docs/narr/viewconfig.rst148
-rw-r--r--pyramid/config/__init__.py1
-rw-r--r--pyramid/config/routes.py38
-rw-r--r--pyramid/config/views.py234
-rw-r--r--pyramid/interfaces.py7
-rw-r--r--pyramid/predicates.py8
-rw-r--r--pyramid/testing.py2
-rw-r--r--pyramid/tests/test_config/test_routes.py30
-rw-r--r--pyramid/tests/test_config/test_views.py42
-rw-r--r--pyramid/tests/test_util.py60
-rw-r--r--pyramid/util.py77
-rw-r--r--tox.ini3
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)
diff --git a/tox.ini b/tox.ini
index 5a73bc426..76428846f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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 =