Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ unreleased
Features
--------

- When a route matches but no view matches due to predicate mismatches,
Pyramid now returns the correct HTTP status instead of always returning 404:

- **405 Method Not Allowed** (with ``Allow`` header) when all views failed
because of ``request_method`` predicates.
- **406 Not Acceptable** when all views failed because of ``accept``
predicates.
- **404 Not Found** remains the default when mismatches are mixed or
unrelated to method/accept.

- Add support for Python 3.12, 3.13, and 3.14.

- Added HTTP 418 error code via `pyramid.httpexceptions.HTTPImATeapot`.
Expand Down Expand Up @@ -73,6 +83,17 @@ Bug Fixes
Backward Incompatibilities
--------------------------

- When all views for a matched route fail due to ``request_method`` predicates,
Pyramid now raises ``HTTPMethodNotAllowed`` (405) instead of
``PredicateMismatch`` (a subclass of ``HTTPNotFound``, 404). Similarly,
``accept`` predicate mismatches now raise ``HTTPNotAcceptable`` (406).

``HTTPMethodNotAllowed`` and ``HTTPNotAcceptable`` do **not** inherit from
``HTTPNotFound`` or ``PredicateMismatch``. Code that catches ``HTTPNotFound``
or registers exception views for ``HTTPNotFound`` will no longer intercept
these responses. Update exception handlers to also catch the new types if
needed.

- Drop support for Python 3.6, 3.7, 3.8, and 3.9.

- Drop support for l*gettext() methods in the i18n module.
Expand Down
25 changes: 21 additions & 4 deletions src/pyramid/config/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,26 @@ def get_views(self, request):
return views
return self.views

def _all_views(self):
views = list(self.views)
for subset in self.media_views.values():
views.extend(subset)
return views

def match(self, context, request):
mismatches = []
for order, view, phash in self.get_views(request):
if not hasattr(view, '__predicated__'):
return view
if view.__predicated__(context, request):
return view
raise PredicateMismatch(self.name)
if hasattr(view, '__predicates__'):
for pred in view.__predicates__:
if not pred(context, request):
mismatches.append((view, pred))
break
PredicateMismatch.raise_if_specialized(mismatches, self._all_views())
raise PredicateMismatch(self.name, mismatches=mismatches)

def __permitted__(self, context, request):
view = self.match(context, request)
Expand All @@ -145,12 +158,15 @@ def __call_permissive__(self, context, request):
return view(context, request)

def __call__(self, context, request):
mismatches = []
for order, view, phash in self.get_views(request):
try:
return view(context, request)
except PredicateMismatch:
except PredicateMismatch as e:
mismatches.append((view, e.predicate))
continue
raise PredicateMismatch(self.name)
PredicateMismatch.raise_if_specialized(mismatches, self._all_views())
raise PredicateMismatch(self.name, mismatches=mismatches)


def attr_wrapped_view(view, info):
Expand Down Expand Up @@ -190,7 +206,8 @@ def predicate_wrapper(context, request):
view_name = getattr(view, '__name__', view)
raise PredicateMismatch(
'predicate mismatch for view %s (%s)'
% (view_name, predicate.text())
% (view_name, predicate.text()),
predicate=predicate,
)
return view(context, request)

Expand Down
44 changes: 44 additions & 0 deletions src/pyramid/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,50 @@ class PredicateMismatch(HTTPNotFound):
exception view.
"""

def __init__(self, *args, predicate=None, mismatches=None, **kwargs):
super().__init__(*args, **kwargs)
self.predicate = predicate
self.mismatches = mismatches

@classmethod
def raise_if_specialized(cls, mismatches, all_views=None):
"""Analyze predicate mismatches and raise 405/406 if appropriate.

``mismatches`` is a list of ``(view, predicate)`` tuples.

``all_views`` is an optional list of ``(order, view, phash)``
tuples used to collect all allowed methods for 405 responses.
"""
if not mismatches:
return
predicates = [p for _, p in mismatches]
if any(p is None for p in predicates):
return

from pyramid.predicates import AcceptPredicate, RequestMethodPredicate

if all(isinstance(p, RequestMethodPredicate) for p in predicates):
from pyramid.httpexceptions import HTTPMethodNotAllowed

methods = set()
if all_views is not None:
for _order, view, _phash in all_views:
if hasattr(view, '__predicates__'):
for pred in view.__predicates__:
if isinstance(pred, RequestMethodPredicate):
methods.update(pred.val)
else:
for _, p in mismatches:
methods.update(p.val)
if methods:
allow = ', '.join(sorted(methods))
raise HTTPMethodNotAllowed(headers={'Allow': allow})

if all(isinstance(p, AcceptPredicate) for p in predicates):
from pyramid.httpexceptions import HTTPNotAcceptable

raise HTTPNotAcceptable()


class URLDecodeError(UnicodeDecodeError):
"""
Expand Down
8 changes: 6 additions & 2 deletions src/pyramid/tweens.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import sys

from pyramid.httpexceptions import HTTPNotFound
from pyramid.httpexceptions import (
HTTPMethodNotAllowed,
HTTPNotAcceptable,
HTTPNotFound,
)
from pyramid.util import reraise


Expand All @@ -11,7 +15,7 @@ def _error_handler(request, exc):

try:
response = request.invoke_exception_view(exc_info)
except HTTPNotFound:
except (HTTPNotFound, HTTPMethodNotAllowed, HTTPNotAcceptable):
# re-raise the original exception as no exception views were
# able to handle the error
reraise(*exc_info)
Expand Down
21 changes: 16 additions & 5 deletions src/pyramid/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from pyramid.exceptions import ConfigurationError, PredicateMismatch
from pyramid.httpexceptions import (
HTTPMethodNotAllowed,
HTTPNotAcceptable,
HTTPNotFound,
HTTPTemporaryRedirect,
default_exceptionresponse_view,
Expand Down Expand Up @@ -655,7 +657,7 @@ def _call_view(
view_classifier=view_classifier,
)

pme = None
mismatches = []
response = None

for view_callable in view_callables:
Expand All @@ -673,11 +675,20 @@ def _call_view(
# permission
response = view_callable(context, request)
return response
except (HTTPMethodNotAllowed, HTTPNotAcceptable):
raise
except PredicateMismatch as _pme:
pme = _pme

if pme is not None:
raise pme
mismatches.append(_pme)

if mismatches:
all_mismatches = []
for e in mismatches:
if e.mismatches:
all_mismatches.extend(e.mismatches)
else:
all_mismatches.append((None, e.predicate))
PredicateMismatch.raise_if_specialized(all_mismatches)
raise mismatches[-1]

return response

Expand Down
Loading