summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Merickel <github@m.merickel.org>2018-10-14 21:03:15 -0500
committerGitHub <noreply@github.com>2018-10-14 21:03:15 -0500
commit433efe06191a7007ca8c5bf8fafee5c7c1439ebb (patch)
tree6765f2177ba5fd0352380db05548d1171517d08e
parentd8a08302f3b38da8a5d9b854afbe346d84c534c2 (diff)
parent364bd7dfef2c6599b126671784b23f9fb7fa6dea (diff)
downloadpyramid-433efe06191a7007ca8c5bf8fafee5c7c1439ebb.tar.gz
pyramid-433efe06191a7007ca8c5bf8fafee5c7c1439ebb.tar.bz2
pyramid-433efe06191a7007ca8c5bf8fafee5c7c1439ebb.zip
Merge pull request #3326 from mmerickel/fix-deprecated-accept-predicate
Overhaul HTTP Accept (mime type) handling in Pyramid
-rw-r--r--CHANGES.rst21
-rw-r--r--docs/glossary.rst6
-rw-r--r--docs/narr/extconfig.rst3
-rw-r--r--docs/narr/viewconfig.rst156
-rw-r--r--pyramid/config/__init__.py1
-rw-r--r--pyramid/config/routes.py64
-rw-r--r--pyramid/config/util.py61
-rw-r--r--pyramid/config/views.py246
-rw-r--r--pyramid/interfaces.py7
-rw-r--r--pyramid/predicates.py18
-rw-r--r--pyramid/testing.py2
-rw-r--r--pyramid/tests/test_config/test_routes.py46
-rw-r--r--pyramid/tests/test_config/test_util.py36
-rw-r--r--pyramid/tests/test_config/test_views.py135
-rw-r--r--pyramid/tests/test_integration.py125
-rw-r--r--setup.cfg4
-rw-r--r--setup.py2
17 files changed, 779 insertions, 154 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index cf13bd6b5..9fecafd2e 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.
@@ -103,6 +113,17 @@ Deprecations
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
--------------------------
diff --git a/docs/glossary.rst b/docs/glossary.rst
index aef0263a8..c14729fc4 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..dd767e3f1 100644
--- a/docs/narr/viewconfig.rst
+++ b/docs/narr/viewconfig.rst
@@ -285,6 +285,34 @@ Non-Predicate Arguments
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
+++++++++++++++++++
@@ -317,17 +345,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.
@@ -424,19 +441,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 +1030,110 @@ 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 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:
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..5d05429a7 100644
--- a/pyramid/config/routes.py
+++ b/pyramid/config/routes.py
@@ -13,12 +13,16 @@ 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
from pyramid.config.util import (
action_method,
+ normalize_accept_offer,
predvalseq,
)
@@ -139,18 +143,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 +225,32 @@ 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. 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 @@ class RoutesConfiguratorMixin(object):
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:
diff --git a/pyramid/config/util.py b/pyramid/config/util.py
index aedebd9e2..05d810f6f 100644
--- a/pyramid/config/util.py
+++ b/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 @@ class PredicateList(object):
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)
diff --git a/pyramid/config/views.py b/pyramid/config/views.py
index 5d46de276..e6baa7c17 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,
@@ -86,7 +88,9 @@ from pyramid.config.util import (
action_method,
DEFAULT_PHASH,
MAX_ORDER,
+ normalize_accept_offer,
predvalseq,
+ sort_accept_offers,
)
urljoin = urlparse.urljoin
@@ -115,7 +119,7 @@ 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:
@@ -134,21 +138,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 = [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
@@ -242,6 +243,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
@@ -535,15 +544,40 @@ class ViewsConfiguratorMixin(object):
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
@@ -566,18 +600,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
@@ -804,22 +826,31 @@ 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.',
+ )
+ 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)
@@ -857,9 +888,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 +1090,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 +1112,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 +1127,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 +1248,106 @@ 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. 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):
"""
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..97edae8a0 100644
--- a/pyramid/predicates.py
+++ b/pyramid/predicates.py
@@ -130,16 +130,26 @@ class HeaderPredicate(object):
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):
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..9f4ce9bc6 100644
--- a/pyramid/tests/test_config/test_routes.py
+++ b/pyramid/tests/test_config/test_routes.py
@@ -182,10 +182,37 @@ 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_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 @@ class DummyRequest:
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
diff --git a/pyramid/tests/test_config/test_util.py b/pyramid/tests/test_config/test_util.py
index 99c67e8c6..540f3d14c 100644
--- a/pyramid/tests/test_config/test_util.py
+++ b/pyramid/tests/test_config/test_util.py
@@ -431,6 +431,42 @@ class TestDeprecatedPredicates(unittest.TestCase):
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'
diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py
index 1c99d2ac5..6565a35d5 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,26 @@ 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_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 @@ class TestViewsConfigurationMixin(unittest.TestCase):
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 @@ 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.views, [
+ (98, 'view6', None), (99, 'view2', None), (100, 'view', None)])
def test_add_with_phash(self):
mv = self._makeOne()
@@ -3437,17 +3522,19 @@ class DummyContext:
pass
class DummyAccept(object):
- def __init__(self, *matches):
+ 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 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 __contains__(self, value):
+ return self.contains
class DummyConfig:
def __init__(self):
@@ -3475,8 +3562,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_integration.py b/pyramid/tests/test_integration.py
index c99e89f59..eedc145ad 100644
--- a/pyramid/tests/test_integration.py
+++ b/pyramid/tests/test_integration.py
@@ -16,6 +16,7 @@ from pyramid.compat import (
)
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 @@ class IntegrationBase(object):
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 @@ class TestConflictApp(unittest.TestCase):
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 @@ class TestConflictApp(unittest.TestCase):
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 @@ class TestConflictApp(unittest.TestCase):
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 @@ class TestConflictApp(unittest.TestCase):
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 @@ class TestConflictApp(unittest.TestCase):
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 @@ class ImperativeIncludeConfigurationTest(unittest.TestCase):
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 @@ class SelfScanAppTest(unittest.TestCase):
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 @@ class WSGIApp2AppTest(unittest.TestCase):
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 @@ class SubrequestAppTest(unittest.TestCase):
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 @@ class RendererScanAppTest(IntegrationBase, unittest.TestCase):
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 @@ class UnicodeInURLTest(unittest.TestCase):
return config
def _makeTestApp(self, config):
- from webtest import TestApp
app = config.make_wsgi_app()
return TestApp(app)
@@ -685,33 +674,121 @@ class UnicodeInURLTest(unittest.TestCase):
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_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 = app.get('/hello', headers={
+ 'Accept': 'text/plain; q=0.9, application/json; q=1.0',
+ }, status=200)
+ self.assertEqual(res.content_type, 'application/json')
+ res = app.get('/hello', headers={'Accept': 'application/*'}, status=200)
+ self.assertEqual(res.content_type, 'application/json')
+ 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_ordering(self):
- res = self.testapp.get('/hello', headers={'Accept': 'application/json; q=1.0, text/plain; q=0.9'}, status=200)
+ 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 = self.testapp.get('/hello', headers={'Accept': 'text/plain; q=0.9, application/json; q=1.0'}, status=200)
+ 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_wildcards(self):
- res = self.testapp.get('/hello', headers={'Accept': 'application/*'}, status=200)
+ 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')
- res = self.testapp.get('/hello', headers={'Accept': 'text/*'}, status=200)
+
+ 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):
diff --git a/setup.cfg b/setup.cfg
index 3bf28ee15..cb74bd24c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -63,7 +63,9 @@ ignore =
# 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
diff --git a/setup.py b/setup.py
index d3d453a0d..aa7e3ab60 100644
--- a/setup.py
+++ b/setup.py
@@ -29,7 +29,7 @@ install_requires = [
'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
]