diff --git a/docs/narr/extconfig.rst b/docs/narr/extconfig.rst index 61bd7a05f6..a9ea93e601 100644 --- a/docs/narr/extconfig.rst +++ b/docs/narr/extconfig.rst @@ -255,6 +255,7 @@ Pre-defined Phases :const:`pyramid.config.PHASE1_CONFIG` +- :meth:`pyramid.config.Configurator.add_accept_view_option` - :meth:`pyramid.config.Configurator.add_renderer` - :meth:`pyramid.config.Configurator.add_route_predicate` - :meth:`pyramid.config.Configurator.add_subscriber_predicate` diff --git a/docs/narr/viewconfig.rst b/docs/narr/viewconfig.rst index c463d297ec..8c511cd5e2 100644 --- a/docs/narr/viewconfig.rst +++ b/docs/narr/viewconfig.rst @@ -285,6 +285,17 @@ Non-Predicate Arguments are just developing stock Pyramid applications. Pay no attention to the man behind the curtain. +``exception_only`` + + When this value is ``True``, the ``context`` argument must be a subclass of + ``Exception``. This flag indicates that only an :term:`exception view` should + be created, and that this view should not match if the traversal + :term:`context` matches the ``context`` argument. If the ``context`` is a + subclass of ``Exception`` and this value is ``False`` (the default), then a + view will be registered to match the traversal :term:`context` as well. + + .. versionadded:: 1.8 + Predicate Arguments +++++++++++++++++++ @@ -317,17 +328,6 @@ configured view. If ``context`` is not supplied, the value ``None``, which matches any resource, is used. -``exception_only`` - - When this value is ``True``, the ``context`` argument must be a subclass of - ``Exception``. This flag indicates that only an :term:`exception view` should - be created, and that this view should not match if the traversal - :term:`context` matches the ``context`` argument. If the ``context`` is a - subclass of ``Exception`` and this value is ``False`` (the default), then a - view will be registered to match the traversal :term:`context` as well. - - .. versionadded:: 1.8 - ``route_name`` If ``route_name`` is supplied, the view callable will be invoked only when the named route has matched. @@ -344,6 +344,20 @@ configured view. request/context pair found via :term:`resource location` does not indicate it matched any configured route. +``accept`` + A :term:`media type` that will be matched against the ``Accept`` HTTP request header. + If this value is specified, it must be a specific media type, such as ``text/html``. + If the media type is acceptable by the ``Accept`` header of the request, or if the ``Accept`` header isn't set at all in the request, this predicate will match. + If this does not match the ``Accept`` header of the request, view matching continues. + + If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is not taken into consideration when deciding whether or not to invoke the associated view callable. + + See :ref:`accept_content_negotation` for more information. + + .. versionchanged:: 1.10 + Media ranges such as ``text/*`` will now raise :class:`pyramid.exceptions.ConfigurationError`. + Previously these values had undefined behavior based on the version of WebOb being used and was never fully supported. + ``request_type`` This value should be an :term:`interface` that the :term:`request` must provide in order for this view to be found and called. @@ -424,19 +438,6 @@ configured view. taken into consideration when deciding whether or not to invoke the associated view callable. -``accept`` - The value of this argument represents a match query for one or more mimetypes - in the ``Accept`` HTTP request header. If this value is specified, it must - be in one of the following forms: a mimetype match token in the form - ``text/plain``, a wildcard mimetype match token in the form ``text/*``, or a - match-all wildcard mimetype match token in the form ``*/*``. If any of the - forms matches the ``Accept`` header of the request, this predicate will be - true. - - If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is not taken - into consideration when deciding whether or not to invoke the associated view - callable. - ``header`` This value represents an HTTP header name or a header name/value pair. @@ -1028,6 +1029,57 @@ these values. .. index:: single: HTTP caching +.. _accept_content_negotation: + +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 below example in which there are two views, sharing the same view callable. Each view specifies uses the accept header to trigger the appropriate response renderer. + +.. code-block:: python + + 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'), + } + +Wildcard Accept header +++++++++++++++++++++++ + +The appropriate view is selected here when the client specifies an unambiguous header such as ``Accept: text/*`` or ``Accept: application/json``. However, by default, if a client specifies ``Accept: */*``, the ordering is undefined. This can be fixed by telling :app:`Pyramid` what the preferred relative ordering is between various accept mimetypes by using :meth:`pyramid.config.Configurator.add_accept_view_option`. For example: + +.. code-block:: python + + from pyramid.config import Configurator + + def main(global_config, **settings): + config = Configurator(settings=settings) + config.add_accept_view_option('text/html') + config.add_accept_view_option( + 'application/json', + weighs_more_than='text/html', + ) + config.scan() + return config.make_wsgi_app() + +Missing Accept header ++++++++++++++++++++++ + +The above example will not match any view if the ``Accept`` header is not specified by the client. This can be solved by adding a fallback view without an ``accept`` predicate, or by modifying one of the predicates to match ``*/*``. For example, below the html response will be returned in all cases unless ``application/json`` is requested specifically. + +.. code-block:: python + + @view_config(accept='application/json', renderer='json') + @view_config(accept='*/*', renderer='templates/hello.jinja2') + def myview(request): + return { + 'name': request.GET.get('name', 'bob'), + } + .. _influencing_http_caching: Influencing HTTP Caching diff --git a/pyramid/config/__init__.py b/pyramid/config/__init__.py index f4fcf413e9..2f4e133f0d 100644 --- a/pyramid/config/__init__.py +++ b/pyramid/config/__init__.py @@ -394,6 +394,7 @@ def setup_registry(self, 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 904c7bd4e8..051bd9edb1 100644 --- a/pyramid/config/routes.py +++ b/pyramid/config/routes.py @@ -139,18 +139,6 @@ def add_route(self, .. 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 +221,27 @@ def add_route(self, case of the header name is not significant. If this predicate returns ``False``, route matching continues. + accept + + A media type that will be matched against the ``Accept`` HTTP + request header. If this value is specified, it must be a specific + media type, such as ``text/html``. If the media type is acceptable + by the ``Accept`` header of the request, or if the ``Accept`` header + isn't set at all in the request, this predicate will match. If this + does not match the ``Accept`` header of the request, 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. + + + .. versionchanged:: 1.10 + Media ranges such as ``text/*`` will now raise + :class:`pyramid.exceptions.ConfigurationError`. Previously, + these values had undefined behavior based on the version of + WebOb being used and was never fully supported. + effective_principals If specified, this value should be a :term:`principal` identifier or @@ -289,6 +298,10 @@ def add_route(self, DeprecationWarning, stacklevel=3 ) + + if accept is not None: + accept = accept.lower() + # these are route predicates; if they do not match, the next route # in the routelist will be tried if request_method is not None: diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 5d46de276a..4eab275420 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -13,6 +13,7 @@ from zope.interface.interfaces import IInterface from pyramid.interfaces import ( + IAcceptOrder, IExceptionViewClassifier, IException, IMultiView, @@ -115,14 +116,14 @@ def __discriminator__(self, context, request): view = self.match(context, request) return view.__discriminator__(context, request) - def add(self, view, order, accept=None, phash=None): + def add(self, view, order, accept=None, phash=None, accept_order=None): if phash is not None: for i, (s, v, h) in enumerate(list(self.views)): if phash == h: self.views[i] = (order, view, phash) return - if accept is None or '*' in accept: + if accept is None: self.views.append((order, view, phash)) self.views.sort(key=operator.itemgetter(0)) else: @@ -134,21 +135,24 @@ def add(self, view, order, accept=None, phash=None): 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 is not None: + sorted_accepts = [] + for accept in accept_order.sorted(): + if accept in accepts: + sorted_accepts.append(accept) + accepts.remove(accept) + sorted_accepts.extend(accepts) + accepts = sorted_accepts + self.accepts = list(accepts) 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 @@ -533,17 +537,17 @@ def wrapper(context, request): very useful for 'civilians' who are just developing stock Pyramid applications. Pay no attention to the man behind the curtain. - accept + exception_only - This value represents a match query for one or more mimetypes in the - ``Accept`` HTTP request header. If this value is specified, it must - be in one of the following forms: a mimetype match token in the form - ``text/plain``, a wildcard mimetype match token in the form - ``text/*`` or a match-all wildcard mimetype match token in the form - ``*/*``. If any of the forms matches the ``Accept`` header of the - request, or if the ``Accept`` header isn't set at all in the request, - this will match the current view. If this does not match the - ``Accept`` header of the request, view matching continues. + .. versionadded:: 1.8 + + When this value is ``True``, the ``context`` argument must be + a subclass of ``Exception``. This flag indicates that only an + :term:`exception view` should be created, and that this view should + not match if the traversal :term:`context` matches the ``context`` + argument. If the ``context`` is a subclass of ``Exception`` and + this value is ``False`` (the default), then a view will be + registered to match the traversal :term:`context` as well. Predicate Arguments @@ -566,18 +570,6 @@ def wrapper(context, request): spelling). If the view should *only* match when handling exceptions, then set the ``exception_only`` to ``True``. - exception_only - - .. versionadded:: 1.8 - - When this value is ``True``, the ``context`` argument must be - a subclass of ``Exception``. This flag indicates that only an - :term:`exception view` should be created, and that this view should - not match if the traversal :term:`context` matches the ``context`` - argument. If the ``context`` is a subclass of ``Exception`` and - this value is ``False`` (the default), then a view will be - registered to match the traversal :term:`context` as well. - route_name This value must match the ``name`` of a :term:`route @@ -677,6 +669,28 @@ def wrapper(context, request): represents a header name or a header name/value pair, the case of the header name is not significant. + accept + + A :term:`media type` that will be matched against the ``Accept`` + HTTP request header. If this value is specified, it must be a + specific media type, such as ``text/html``. If the media type is + acceptable by the ``Accept`` header of the request, or if the + ``Accept`` header isn't set at all in the request, this predicate + will match. If this does not match the ``Accept`` header of the + request, view matching continues. + + If ``accept`` is not specified, the ``HTTP_ACCEPT`` HTTP header is + not taken into consideration when deciding whether or not to invoke + the associated view callable. + + See :ref:`accept_content_negotation` for more information. + + .. versionchanged:: 1.10 + Media ranges such as ``text/*`` will now raise + :class:`pyramid.exceptions.ConfigurationError`. Previously, + these values had undefined behavior based on the version of + WebOb being used and was never fully supported. + path_info This value represents a regular expression pattern that will @@ -804,6 +818,9 @@ def wrapper(context, request): stacklevel=4, ) + if accept is not None: + accept = accept.lower() + view = self.maybe_dotted(view) context = self.maybe_dotted(context) for_ = self.maybe_dotted(for_) @@ -857,9 +874,6 @@ def view(context, request): name=renderer, package=self.package, registry=self.registry) - if accept is not None: - accept = accept.lower() - introspectables = [] ovals = view_options.copy() ovals.update(dict( @@ -1104,8 +1118,11 @@ def regclosure(): multiview = MultiView(name) old_accept = getattr(old_view, '__accept__', None) old_order = getattr(old_view, '__order__', MAX_ORDER) + # 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_accept, old_phash) - multiview.add(derived_view, order, accept, phash) + accept_order = self.registry.queryUtility(IAcceptOrder) + multiview.add(derived_view, order, accept, phash, accept_order) for view_type in (IView, ISecuredView): # unregister any existing views self.registry.adapters.unregister( @@ -1222,6 +1239,66 @@ def add_default_view_predicates(self): ): 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', + '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``, + ``application/json`` or ``text/html``. + + ``weighs_more_than`` and ``weighs_less_than`` control the ordering + of media types. Each value may be a string or a list of strings. + + See :ref:`accept_content_negotation` for more information. + + .. versionadded:: 1.10 + + """ + discriminator = ('accept view order', value) + intr = self.introspectable( + 'accept view order', + value, + value, + 'accept view order') + intr['value'] = value + intr['weighs_more_than'] = weighs_more_than + intr['weighs_less_than'] = weighs_less_than + def register(): + sorter = self.registry.queryUtility(IAcceptOrder) + if sorter is None: + sorter = TopologicalSorter() + self.registry.registerUtility(sorter, IAcceptOrder) + sorter.add( + value, value, + after=weighs_more_than, + before=weighs_less_than, + ) + self.action(discriminator, register, introspectables=(intr,), + order=PHASE1_CONFIG) # must be registered before add_view + @action_method def add_view_deriver(self, deriver, name=None, under=None, over=None): """ diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index bedfb60b37..17673087d8 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 5e54badff4..4f63122aac 100644 --- a/pyramid/predicates.py +++ b/pyramid/predicates.py @@ -132,6 +132,10 @@ def __call__(self, context, request): class AcceptPredicate(object): def __init__(self, val, config): self.val = val + if '*' in self.val: + raise ConfigurationError( + '"accept" predicate only accepts specific media types', + ) def text(self): return 'accept = %s' % (self.val,) @@ -139,7 +143,7 @@ def text(self): phash = text def __call__(self, context, request): - return self.val in request.accept + return bool(request.accept.acceptable_offers([self.val])) class ContainmentPredicate(object): def __init__(self, val, config): diff --git a/pyramid/testing.py b/pyramid/testing.py index 7ff4c2f731..4986c0e279 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()