Michael Merickel
2018-10-15 433efe06191a7007ca8c5bf8fafee5c7c1439ebb
Merge pull request #3326 from mmerickel/fix-deprecated-accept-predicate

Overhaul HTTP Accept (mime type) handling in Pyramid
17 files modified
935 ■■■■ changed files
CHANGES.rst 21 ●●●●● patch | view | raw | blame | history
docs/glossary.rst 6 ●●●●● patch | view | raw | blame | history
docs/narr/extconfig.rst 3 ●●●● patch | view | raw | blame | history
docs/narr/viewconfig.rst 156 ●●●● patch | view | raw | blame | history
pyramid/config/__init__.py 1 ●●●● patch | view | raw | blame | history
pyramid/config/routes.py 64 ●●●● patch | view | raw | blame | history
pyramid/config/util.py 61 ●●●●● patch | view | raw | blame | history
pyramid/config/views.py 246 ●●●● patch | view | raw | blame | history
pyramid/interfaces.py 7 ●●●●● patch | view | raw | blame | history
pyramid/predicates.py 18 ●●●● patch | view | raw | blame | history
pyramid/testing.py 2 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_config/test_routes.py 46 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_config/test_util.py 36 ●●●●● patch | view | raw | blame | history
pyramid/tests/test_config/test_views.py 135 ●●●● patch | view | raw | blame | history
pyramid/tests/test_integration.py 127 ●●●● patch | view | raw | blame | history
setup.cfg 4 ●●● patch | view | raw | blame | history
setup.py 2 ●●● patch | view | raw | blame | history
CHANGES.rst
@@ -57,6 +57,16 @@
- 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.
@@ -103,6 +113,17 @@
  implementation if you're still using these features.
  See https://github.com/Pylons/pyramid/pull/3353
- Media ranges are deprecated in the ``accept`` argument of
  ``pyramid.config.Configurator.add_route``. Use a list of explicit
  media types to ``add_route`` to support multiple types.
- Media ranges are deprecated in the ``accept`` argument of
  ``pyramid.config.Configurator.add_view``.  There is no replacement for
  ranges to ``add_view``, but after much discussion the workflow is
  fundamentally ambiguous in the face of various client-supplied values for
  the ``Accept`` header.
  See https://github.com/Pylons/pyramid/pull/3326
Backward Incompatibilities
--------------------------
docs/glossary.rst
@@ -1209,3 +1209,9 @@
   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.
docs/narr/extconfig.rst
@@ -255,11 +255,12 @@
: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`
docs/narr/viewconfig.rst
@@ -285,6 +285,34 @@
  are just developing stock Pyramid applications. Pay no attention to the man
  behind the curtain.
``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`` or ``text/html;level=1``.
  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.
  The ``accept`` argument is technically not a predicate and does not support wrapping with :func:`pyramid.config.not_`.
  See :ref:`accept_content_negotiation` for more information.
  .. versionchanged:: 1.10
      Specifying a media range is deprecated and will be removed in :app:`Pyramid` 2.0.
      Use explicit media types to avoid any ambiguities in content negotiation.
``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
+++++++++++++++++++
@@ -316,17 +344,6 @@
  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
@@ -423,19 +440,6 @@
  If ``xhr`` is not specified, the ``HTTP_X_REQUESTED_WITH`` HTTP header is not
  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 +1030,110 @@
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 views configured.
.. 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
Each view relies on the ``Accept`` header to trigger an appropriate response renderer.
The appropriate view is selected here when the client specifies headers such as ``Accept: text/*`` or ``Accept: application/json, text/html;q=0.9`` in which only one of the views matches or it's clear based on the preferences which one should win.
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 an ``Accept`` header such that it's not clear which view should win.
For example:
- ``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 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()
Now, the ``application/json`` view should always be preferred in cases where the client wasn't clear.
.. 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.
For any set of media type offers with the same ``type/subtype``, the offers with params will weigh more than the bare ``type/subtype`` offer.
This means that ``text/plain;charset=utf8`` will always be offered before ``text/plain``.
By default, within a given ``type/subtype``, the order of offers is unspecified.
For example, ``text/plain;charset=utf8`` versus ``text/plain;charset=latin1`` are sorted randomly.
Similarly, between media types the order is also unspecified other than the defaults described below.
For example, ``image/jpeg`` versus ``image/png`` versus ``application/pdf``.
In these cases, the ordering 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/html')
    config.add_accept_view_order('text/plain;charset=latin-1')
    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/subtype;params``.
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:
pyramid/config/__init__.py
@@ -394,6 +394,7 @@
        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()
pyramid/config/routes.py
@@ -13,12 +13,16 @@
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
from pyramid.config.util import (
    action_method,
    normalize_accept_offer,
    predvalseq,
)
@@ -139,18 +143,6 @@
          .. 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 +225,32 @@
          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.  If this value is specified, it may be a
          specific media type such as ``text/html``, 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.
          Unlike the ``accept`` argument to
          :meth:`pyramid.config.Configurator.add_view`, this value is
          strictly a predicate and supports :func:`pyramid.config.not_`.
          .. versionchanged:: 1.10
              Specifying a media range is deprecated due to changes in WebOb
              and ambiguities that occur when trying to match ranges against
              ranges in the ``Accept`` header. Support will be removed in
              :app:`Pyramid` 2.0. Use a list of specific media types to match
              more than one type.
        effective_principals
          If specified, this value should be a :term:`principal` identifier or
@@ -289,6 +307,26 @@
                DeprecationWarning,
                stacklevel=3
                )
        if accept is not None:
            if not is_nonstr_iter(accept):
                if '*' in accept:
                    warnings.warn(
                        ('Passing a media range to the "accept" argument of '
                         'Configurator.add_route is deprecated as of Pyramid '
                         '1.10. Use a list of explicit media types.'),
                        DeprecationWarning,
                        stacklevel=3,
                        )
                # XXX switch this to False when range support is dropped
                accept = [normalize_accept_offer(accept, allow_range=True)]
            else:
                accept = [
                    normalize_accept_offer(accept_option)
                    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:
pyramid/config/util.py
@@ -1,6 +1,7 @@
import functools
from hashlib import md5
import traceback
from webob.acceptparse import Accept
from zope.interface import implementer
from pyramid.compat import (
@@ -218,3 +219,63 @@
            score = score | bit
        order = (MAX_ORDER - score) / (len(preds) + 1)
        return order, preds, phash.hexdigest()
def normalize_accept_offer(offer, allow_range=False):
    if allow_range and '*' in offer:
        return offer.lower()
    return str(Accept.parse_offer(offer))
def sort_accept_offers(offers, order=None):
    """
    Sort a list of offers by preference.
    For a given ``type/subtype`` category of offers, this algorithm will
    always sort offers with params higher than the bare offer.
    :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):
        """
        (type_weight, params_weight)
        type_weight:
            - index of specific ``type/subtype`` in order list
            - ``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)
        type_w = find_order_index(
            parsed.type + '/' + parsed.subtype,
            max_weight,
        )
        if parsed.params:
            param_w = find_order_index(value, max_weight)
        else:
            param_w = max_weight + 1
        return (type_w, param_w)
    return sorted(offers, key=offer_sort_key)
pyramid/config/views.py
@@ -5,6 +5,7 @@
import os
import warnings
from webob.acceptparse import Accept
from zope.interface import (
    Interface,
    implementedBy,
@@ -13,6 +14,7 @@
from zope.interface.interfaces import IInterface
from pyramid.interfaces import (
    IAcceptOrder,
    IExceptionViewClassifier,
    IException,
    IMultiView,
@@ -86,7 +88,9 @@
    action_method,
    DEFAULT_PHASH,
    MAX_ORDER,
    normalize_accept_offer,
    predvalseq,
    sort_accept_offers,
    )
urljoin = urlparse.urljoin
@@ -115,7 +119,7 @@
        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:
@@ -134,21 +138,18 @@
            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 = [v for _, v in 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
@@ -241,6 +242,14 @@
        defaults.update(kw)
        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
@@ -535,15 +544,40 @@
        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
          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`` or ``text/html;level=1``.
          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 will match the current view. If this does not match the
          ``Accept`` header of the request, view matching continues.
          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.
          The ``accept`` argument is technically not a predicate and does
          not support wrapping with :func:`pyramid.config.not_`.
          See :ref:`accept_content_negotiation` for more information.
          .. versionchanged:: 1.10
              Specifying a media range is deprecated and will be removed in
              :app:`Pyramid` 2.0. Use explicit media types to avoid any
              ambiguities in content negotiation.
        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.
        Predicate Arguments
@@ -565,18 +599,6 @@
          to ``add_view`` as ``for_`` (an older, still-supported
          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
@@ -804,22 +826,31 @@
                stacklevel=4,
                )
        if accept is not None:
            if is_nonstr_iter(accept):
                raise ConfigurationError(
                    'A list is not supported in the "accept" view predicate.',
                )
            if '*' in accept:
                warnings.warn(
                    ('Passing a media range to the "accept" argument of '
                     'Configurator.add_view is deprecated as of Pyramid 1.10. '
                     'Use explicit media types to avoid ambiguities in '
                     'content negotiation that may impact your users.'),
                    DeprecationWarning,
                    stacklevel=4,
                    )
            # XXX when media ranges are gone, switch allow_range=False
            accept = normalize_accept_offer(accept, allow_range=True)
        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)
@@ -856,9 +887,6 @@
            renderer = renderers.RendererHelper(
                name=renderer, package=self.package,
                registry=self.registry)
        if accept is not None:
            accept = accept.lower()
        introspectables = []
        ovals = view_options.copy()
@@ -1062,7 +1090,17 @@
                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:
@@ -1073,21 +1111,6 @@
                    view_iface,
                    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
@@ -1104,8 +1127,11 @@
                    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 +1248,106 @@
            ):
            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. If
        all options for ``weighs_more_than`` (or ``weighs_less_than``) cannot
        be found, it is an error.
        Earlier calls to ``add_accept_view_order`` are given higher priority
        over later calls, assuming similar constraints but standard conflict
        resolution mechanisms can be used to override constraints.
        See :ref:`accept_content_negotiation` for more information.
        .. versionadded:: 1.10
        """
        def check_type(than):
            than_type, than_subtype, than_params = Accept.parse_offer(than)
            # text/plain vs text/html;charset=utf8
            if bool(offer_params) ^ bool(than_params):
                raise ConfigurationError(
                    'cannot compare a media type with params to one without '
                    'params')
            # 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 different media types')
        def normalize_types(thans):
            thans = [normalize_accept_offer(than) for than in thans]
            for than in thans:
                check_type(than)
            return thans
        value = normalize_accept_offer(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_types(weighs_more_than)
        if weighs_less_than:
            if not is_nonstr_iter(weighs_less_than):
                weighs_less_than = [weighs_less_than]
            weighs_less_than = normalize_types(weighs_less_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,
                before=weighs_more_than,
                after=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):
        """
pyramid/interfaces.py
@@ -586,6 +586,13 @@
    """ *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):
pyramid/predicates.py
@@ -130,16 +130,26 @@
        return self.val.match(val) is not None
class AcceptPredicate(object):
    def __init__(self, val, config):
        self.val = val
    _is_using_deprecated_ranges = False
    def __init__(self, values, config):
        if not is_nonstr_iter(values):
            values = (values,)
        # deprecated media ranges were only supported in versions of the
        # predicate that didn't support lists, so check it here
        if len(values) == 1 and '*' in values[0]:
            self._is_using_deprecated_ranges = True
        self.values = values
    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
        if self._is_using_deprecated_ranges:
            return self.values[0] in request.accept
        return bool(request.accept.acceptable_offers(self.values))
class ContainmentPredicate(object):
    def __init__(self, val, config):
pyramid/testing.py
@@ -474,7 +474,9 @@
        # 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()
pyramid/tests/test_config/test_routes.py
@@ -182,10 +182,37 @@
        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_with_wildcard_accept(self):
        config = self._makeOne(autocommit=True)
        config.add_route('name', 'path', accept='text/*')
        route = self._assertRoute(config, 'name', 'path', 1)
        predicate = route.predicates[0]
        request = self._makeRequest(config)
        request.accept = DummyAccept('text/xml', contains=True)
        self.assertEqual(predicate(None, request), True)
        request = self._makeRequest(config)
        request.accept = DummyAccept('application/json', contains=False)
        self.assertEqual(predicate(None, request), False)
    def test_add_route_no_pattern_with_path(self):
@@ -253,3 +280,18 @@
        self.environ = environ
        self.params = {}
        self.cookies = {}
class DummyAccept(object):
    def __init__(self, *matches, **kw):
        self.matches = list(matches)
        self.contains = kw.pop('contains', False)
    def acceptable_offers(self, offers):
        results = []
        for match in self.matches:
            if match in offers:
                results.append((match, 1.0))
        return results
    def __contains__(self, value):
        return self.contains
pyramid/tests/test_config/test_util.py
@@ -431,6 +431,42 @@
            from pyramid.config.predicates import XHRPredicate
            self.assertEqual(len(w), 1)
class Test_sort_accept_offers(unittest.TestCase):
    def _callFUT(self, offers, order=None):
        from pyramid.config.util import sort_accept_offers
        return sort_accept_offers(offers, order)
    def test_default_specificities(self):
        result = self._callFUT(['text/html', 'text/html;charset=utf8'])
        self.assertEqual(result, [
            'text/html;charset=utf8', 'text/html',
        ])
    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'])
class DummyCustomPredicate(object):
    def __init__(self):
        self.__text__ = 'custom predicate'
pyramid/tests/test_config/test_views.py
@@ -842,7 +842,7 @@
        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 @@
        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 @@
        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 @@
        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,26 @@
        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_range_accept_match(self):
        from pyramid.renderers import null_renderer
        view = lambda *arg: 'OK'
        config = self._makeOne(autocommit=True)
        config.add_view(view=view, accept='text/*', renderer=null_renderer)
        wrapper = self._getViewCallable(config)
        request = self._makeRequest(config)
        request.accept = DummyAccept('text/html', contains=True)
        self.assertEqual(wrapper(None, request), 'OK')
    def test_add_view_with_range_accept_nomatch(self):
        view = lambda *arg: 'OK'
        config = self._makeOne(autocommit=True)
        config.add_view(view=view, accept='text/*')
        wrapper = self._getViewCallable(config)
        request = self._makeRequest(config)
        request.accept = DummyAccept('application/json', contains=False)
        self._assertNotFound(wrapper, None, request)
    def test_add_view_with_containment_true(self):
@@ -2389,6 +2408,73 @@
        request.exception = Exception()
        self.assertEqual(derived_view(None, request), 'OK')
    def test_add_view_does_not_accept_iterable_accept(self):
        from pyramid.exceptions import ConfigurationError
        config = self._makeOne(autocommit=True)
        self.assertRaises(
            ConfigurationError, config.add_view, accept=['image/*', 'text/*'],
        )
    def test_default_accept_view_order(self):
        from pyramid.interfaces import IAcceptOrder
        config = self._makeOne(autocommit=True)
        order = config.registry.getUtility(IAcceptOrder)
        result = [v for _, v in order.sorted()]
        self.assertEqual(result, [
            'text/html',
            'application/xhtml+xml',
            'application/xml',
            'text/xml',
            'text/plain',
            'application/json',
        ])
    def test_add_accept_view_order_override(self):
        from pyramid.interfaces import IAcceptOrder
        config = self._makeOne(autocommit=False)
        config.add_accept_view_order(
            'text/html',
            weighs_more_than='text/xml',
            weighs_less_than='application/xml',
        )
        config.commit()
        order = config.registry.getUtility(IAcceptOrder)
        result = [v for _, v in order.sorted()]
        self.assertEqual(result, [
            'application/xhtml+xml',
            'application/xml',
            'text/html',
            'text/xml',
            'text/plain',
            'application/json',
        ])
    def test_add_accept_view_order_throws_on_wildcard(self):
        config = self._makeOne(autocommit=True)
        self.assertRaises(
            ValueError, config.add_accept_view_order, '*/*',
        )
    def test_add_accept_view_order_throws_on_type_mismatch(self):
        config = self._makeOne(autocommit=True)
        self.assertRaises(
            ValueError, config.add_accept_view_order,
            'text/*', weighs_more_than='text/html',
        )
        self.assertRaises(
            ValueError, config.add_accept_view_order,
            'text/html', weighs_less_than='application/*',
        )
        self.assertRaises(
            ConfigurationError, config.add_accept_view_order,
            'text/html', weighs_more_than='text/html;charset=utf8',
        )
        self.assertRaises(
            ConfigurationError, config.add_accept_view_order,
            'text/html;charset=utf8',
            weighs_more_than='text/plain;charset=utf8',
        )
class Test_runtime_exc_view(unittest.TestCase):
    def _makeOne(self, view1, view2):
        from pyramid.config.views import runtime_exc_view
@@ -2499,19 +2585,18 @@
        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.views, [
            (98, 'view6', None), (99, 'view2', None), (100, 'view', None)])
    def test_add_with_phash(self):
        mv = self._makeOne()
@@ -3437,17 +3522,19 @@
    pass
class DummyAccept(object):
    def __init__(self, *matches):
    def __init__(self, *matches, **kw):
        self.matches = list(matches)
        self.contains = kw.pop('contains', False)
    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
    def __contains__(self, value):
        return self.contains
class DummyConfig:
    def __init__(self):
@@ -3475,8 +3562,8 @@
    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):
pyramid/tests/test_integration.py
@@ -16,6 +16,7 @@
    )
from zope.interface import Interface
from webtest import TestApp
# 5 years from now (more or less)
fiveyrsfuture = datetime.datetime.utcnow() + datetime.timedelta(5*365)
@@ -65,7 +66,6 @@
                              package=self.package)
        config.include(self.package)
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        self.config = config
@@ -482,7 +482,6 @@
        config = self._makeConfig()
        config.include(self.package)
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        res = self.testapp.get('/')
        self.assertTrue(b'a view' in res.body)
@@ -497,7 +496,6 @@
            return Response('this view')
        config.add_view(thisview)
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        res = self.testapp.get('/')
        self.assertTrue(b'this view' in res.body)
@@ -510,7 +508,6 @@
            return Response('this view')
        config.add_view(thisview, route_name='aroute')
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        res = self.testapp.get('/route')
        self.assertTrue(b'this view' in res.body)
@@ -519,7 +516,6 @@
        config = self._makeConfig()
        config.include(self.package)
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        res = self.testapp.get('/protected', status=403)
        self.assertTrue(b'403 Forbidden' in res.body)
@@ -531,7 +527,6 @@
        config.set_authorization_policy(DummySecurityPolicy('fred'))
        config.set_authentication_policy(DummySecurityPolicy(permissive=True))
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        res = self.testapp.get('/protected', status=200)
        self.assertTrue('protected view' in res)
@@ -543,7 +538,6 @@
        from pyramid.tests.pkgs.includeapp1.root import configure
        configure(config)
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        self.config = config
@@ -567,7 +561,6 @@
        from pyramid.tests.test_config.pkgs.selfscan import main
        config = main()
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        self.config = config
@@ -587,7 +580,6 @@
        from pyramid.tests.pkgs.wsgiapp2app import main
        config = main()
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        self.config = config
@@ -603,7 +595,6 @@
        from pyramid.tests.pkgs.subrequestapp import main
        config = main()
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        self.config = config
@@ -635,7 +626,6 @@
    def test_rescan(self):
        self.config.scan('pyramid.tests.pkgs.rendererscanapp')
        app = self.config.make_wsgi_app()
        from webtest import TestApp
        testapp = TestApp(app)
        res = testapp.get('/one', status=200)
        self.assertTrue(b'One!' in res.body)
@@ -649,7 +639,6 @@
        return config
    def _makeTestApp(self, config):
        from webtest import TestApp
        app = config.make_wsgi_app()
        return TestApp(app)
@@ -685,33 +674,121 @@
class AcceptContentTypeTest(unittest.TestCase):
    def setUp(self):
    def _makeConfig(self):
        def hello_view(request):
            return {'message': 'Hello!'}
        from pyramid.config import Configurator
        config = Configurator()
        config.add_route('hello', '/hello')
        config.add_view(hello_view, route_name='hello', accept='text/plain', renderer='string')
        config.add_view(hello_view, route_name='hello', accept='application/json', renderer='json')
        config.add_view(hello_view, route_name='hello',
                        accept='text/plain', renderer='string')
        config.add_view(hello_view, route_name='hello',
                        accept='application/json', renderer='json')
        def hello_fallback_view(request):
            request.response.content_type = 'text/x-fallback'
            return 'hello fallback'
        config.add_view(hello_fallback_view, route_name='hello',
                        renderer='string')
        return config
    def _makeTestApp(self, config):
        app = config.make_wsgi_app()
        from webtest import TestApp
        self.testapp = TestApp(app)
        return TestApp(app)
    def tearDown(self):
        import pyramid.config
        pyramid.config.global_registries.empty()
        pyramid.config.global_registries.empty()
    def test_ordering(self):
        res = self.testapp.get('/hello', headers={'Accept': 'application/json; q=1.0, text/plain; q=0.9'}, status=200)
    def test_client_side_ordering(self):
        config = self._makeConfig()
        app = self._makeTestApp(config)
        res = app.get('/hello', headers={
            'Accept': 'application/json; q=1.0, text/plain; q=0.9',
        }, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = self.testapp.get('/hello', headers={'Accept': 'text/plain; q=0.9, application/json; q=1.0'}, status=200)
        res = app.get('/hello', headers={
            'Accept': 'text/plain; q=0.9, application/json; q=1.0',
        }, status=200)
        self.assertEqual(res.content_type, 'application/json')
    def test_wildcards(self):
        res = self.testapp.get('/hello', headers={'Accept': 'application/*'}, status=200)
        res = app.get('/hello', headers={'Accept': 'application/*'}, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = self.testapp.get('/hello', headers={'Accept': 'text/*'}, status=200)
        res = app.get('/hello', headers={'Accept': 'text/*'}, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', headers={'Accept': 'something/else'}, status=200)
        self.assertEqual(res.content_type, 'text/x-fallback')
    def test_default_server_side_ordering(self):
        config = self._makeConfig()
        app = self._makeTestApp(config)
        res = app.get('/hello', headers={
            'Accept': 'application/json, text/plain',
        }, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', headers={
            'Accept': 'text/plain, application/json',
        }, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', headers={'Accept': '*/*'}, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', headers={'Accept': 'invalid'}, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        res = app.get('/hello', headers={'Accept': 'something/else'}, status=200)
        self.assertEqual(res.content_type, 'text/x-fallback')
    def test_custom_server_side_ordering(self):
        config = self._makeConfig()
        config.add_accept_view_order(
            'application/json', weighs_more_than='text/plain')
        app = self._makeTestApp(config)
        res = app.get('/hello', headers={
            'Accept': 'application/json, text/plain',
        }, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = app.get('/hello', headers={
            'Accept': 'text/plain, application/json',
        }, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = app.get('/hello', headers={'Accept': '*/*'}, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = app.get('/hello', status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = app.get('/hello', headers={'Accept': 'invalid'}, status=200)
        self.assertEqual(res.content_type, 'application/json')
        res = app.get('/hello', headers={'Accept': 'something/else'}, status=200)
        self.assertEqual(res.content_type, 'text/x-fallback')
    def test_deprecated_ranges_in_route_predicate(self):
        config = self._makeConfig()
        config.add_route('foo', '/foo', accept='text/*')
        config.add_view(lambda r: 'OK', route_name='foo', renderer='string')
        app = self._makeTestApp(config)
        res = app.get('/foo', headers={
            'Accept': 'application/json; q=1.0, text/plain; q=0.9',
        }, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        self.assertEqual(res.body, b'OK')
        res = app.get('/foo', headers={
            'Accept': 'application/json',
        }, status=404)
        self.assertEqual(res.content_type, 'application/json')
    def test_deprecated_ranges_in_view_predicate(self):
        config = self._makeConfig()
        config.add_route('foo', '/foo')
        config.add_view(lambda r: 'OK', route_name='foo',
                        accept='text/*', renderer='string')
        app = self._makeTestApp(config)
        res = app.get('/foo', headers={
            'Accept': 'application/json; q=1.0, text/plain; q=0.9',
        }, status=200)
        self.assertEqual(res.content_type, 'text/plain')
        self.assertEqual(res.body, b'OK')
        res = app.get('/foo', headers={
            'Accept': 'application/json',
        }, status=404)
        self.assertEqual(res.content_type, 'application/json')
class DummyContext(object):
setup.cfg
@@ -63,7 +63,9 @@
    # W293: blank line contains whitespace
    W293,
    # W391: blank line at end of file
    W391
    W391,
    # W503: line break before binary operator
    W503
exclude = pyramid/tests/,pyramid/compat.py,pyramid/resource.py
show-source = True
setup.py
@@ -29,7 +29,7 @@
    'setuptools',
    'translationstring >= 0.4',  # py3 compat
    'venusian >= 1.0',  # ``ignore``
    'webob >= 1.8.2',  # cookies.make_cookie allows non-bytes samesite
    'webob >= 1.8.3',  # Accept.parse_offer
    'zope.deprecation >= 3.5.0',  # py3 compat
    'zope.interface >= 3.8.0',  # has zope.interface.registry
]