From 433efe06191a7007ca8c5bf8fafee5c7c1439ebb Mon Sep 17 00:00:00 2001
From: Michael Merickel <github@m.merickel.org>
Date: Mon, 15 Oct 2018 04:03:15 +0200
Subject: [PATCH] Merge pull request #3326 from mmerickel/fix-deprecated-accept-predicate

---
 pyramid/config/views.py |  246 +++++++++++++++++++++++++++++++++++++------------
 1 files changed, 186 insertions(+), 60 deletions(-)

diff --git a/pyramid/config/views.py b/pyramid/config/views.py
index 5d46de2..e6baa7c 100644
--- a/pyramid/config/views.py
+++ b/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):
         """

--
Gitblit v1.9.3