Repository: pallets/werkzeug

Issue:
MultiDict key errors should be more descriptive
`MultiDict` returns a `BadRequestKeyError(key)` when a key is not found. However, the exception doesn't use the key when generating the message to send back, it always gives the generic "bad request" message.

This is a constant source of confusion on Stack Overflow because the developer never sees the key error and so they can't track down the issue unless they already know to recognize that error.

Instead, we can use `BadRequestKeyError(key, description="KeyError: '{0}'".format(key))` to tell the user what specifically went wrong. That might be giving too much information away outside a debug context though, so alternatively or additionally we should log the `KeyError` to the console.


Discussion:
This is partially addressed by pallets/flask#382 so the errors are propagated to the debugger in debug mode. However, the error message is still not descriptive, the developer has to look in the traceback and see that the error occurred while accessing a bad key.
Looking at the Flask issue closer, I can manually set the description in `handle_user_exception` if it's still set to the default.
If we decide to always show the key error in the description, we can remove this from Flask:
https://github.com/pallets/flask/blob/9049755e3fac192d5c0cca5092a74a67e562cdc6/flask/app.py#L1584-L1593. Flask is only showing it in debug mode.
Anything I could do over here @davidism ? If I'm correct this should require changes in both Flask and Werkzeug?

Resolving patch:
diff --git a/werkzeug/datastructures.py b/werkzeug/datastructures.py
--- a/werkzeug/datastructures.py
+++ b/werkzeug/datastructures.py
@@ -423,6 +423,7 @@ def __getitem__(self, key):
         :param key: The key to be looked up.
         :raise KeyError: if the key does not exist.
         """
+
         if key in self:
             lst = dict.__getitem__(self, key)
             if len(lst) > 0:
@@ -633,13 +634,13 @@ def pop(self, key, default=_missing):
             lst = dict.pop(self, key)
 
             if len(lst) == 0:
-                raise exceptions.BadRequestKeyError()
+                raise exceptions.BadRequestKeyError(key)
 
             return lst[0]
-        except KeyError as e:
+        except KeyError:
             if default is not _missing:
                 return default
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(key)
 
     def popitem(self):
         """Pop an item from the dict."""
@@ -647,11 +648,11 @@ def popitem(self):
             item = dict.popitem(self)
 
             if len(item[1]) == 0:
-                raise exceptions.BadRequestKeyError()
+                raise exceptions.BadRequestKeyError(item)
 
             return (item[0], item[1][0])
         except KeyError as e:
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(e.args[0])
 
     def poplist(self, key):
         """Pop the list for a key from the dict.  If the key is not in the dict
@@ -668,7 +669,7 @@ def popitemlist(self):
         try:
             return dict.popitem(self)
         except KeyError as e:
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(e.args[0])
 
     def __copy__(self):
         return self.copy()
@@ -864,10 +865,10 @@ def poplist(self, key):
     def pop(self, key, default=_missing):
         try:
             buckets = dict.pop(self, key)
-        except KeyError as e:
+        except KeyError:
             if default is not _missing:
                 return default
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(key)
         for bucket in buckets:
             bucket.unlink(self)
         return buckets[0].value
@@ -876,7 +877,7 @@ def popitem(self):
         try:
             key, buckets = dict.popitem(self)
         except KeyError as e:
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(e.args[0])
         for bucket in buckets:
             bucket.unlink(self)
         return key, buckets[0].value
@@ -885,7 +886,7 @@ def popitemlist(self):
         try:
             key, buckets = dict.popitem(self)
         except KeyError as e:
-            raise exceptions.BadRequestKeyError(str(e))
+            raise exceptions.BadRequestKeyError(e.args[0])
         for bucket in buckets:
             bucket.unlink(self)
         return key, [x.value for x in buckets]
diff --git a/werkzeug/exceptions.py b/werkzeug/exceptions.py
--- a/werkzeug/exceptions.py
+++ b/werkzeug/exceptions.py
@@ -84,21 +84,44 @@ class HTTPException(Exception):
     description = None
 
     def __init__(self, description=None, response=None):
-        Exception.__init__(self)
+        super(Exception, self).__init__()
         if description is not None:
             self.description = description
         self.response = response
 
     @classmethod
     def wrap(cls, exception, name=None):
-        """This method returns a new subclass of the exception provided that
-        also is a subclass of `BadRequest`.
+        """Create an exception that is a subclass of the calling HTTP
+        exception and the ``exception`` argument.
+
+        The first argument to the class will be passed to the
+        wrapped ``exception``, the rest to the HTTP exception. If
+        ``self.args`` is not empty, the wrapped exception message is
+        added to the HTTP exception description.
+
+        .. versionchanged:: 0.15
+            The description includes the wrapped exception message.
         """
         class newcls(cls, exception):
-
             def __init__(self, arg=None, *args, **kwargs):
-                cls.__init__(self, *args, **kwargs)
-                exception.__init__(self, arg)
+                super(cls, self).__init__(*args, **kwargs)
+
+                if arg is None:
+                    exception.__init__(self)
+                else:
+                    exception.__init__(self, arg)
+
+            def get_description(self, environ=None):
+                out = super(cls, self).get_description(environ=environ)
+
+                if self.args:
+                    out += "<p><pre><code>{}: {}</code></pre></p>".format(
+                        exception.__name__,
+                        escape(exception.__str__(self))
+                    )
+
+                return out
+
         newcls.__module__ = sys._getframe(1).f_globals.get('__name__')
         newcls.__name__ = name or cls.__name__ + exception.__name__
         return newcls
@@ -739,7 +762,6 @@ def abort(status, *args, **kwargs):
 #: bad request.  Used by a lot of the datastructures.
 BadRequestKeyError = BadRequest.wrap(KeyError)
 
-
 # imported here because of circular dependencies of werkzeug.utils
 from werkzeug.utils import escape
 from werkzeug.http import HTTP_STATUS_CODES


Test patch:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -377,7 +377,7 @@ def test_multidict_popitem_raise_badrequestkeyerror_for_empty_list_value(self):
 
         md.setlistdefault('empty', [])
 
-        with pytest.raises(KeyError):
+        with pytest.raises(BadRequestKeyError):
             md.popitem()
 
     def test_setlistdefault(self):
@@ -502,9 +502,9 @@ def test_ordered_interface(self):
         d['foo'] = 42
         d.setlist('foo', [1, 2])
         assert d.getlist('foo') == [1, 2]
-
         with pytest.raises(BadRequestKeyError):
             d.pop('missing')
+
         with pytest.raises(BadRequestKeyError):
             d['missing']
 
@@ -544,6 +544,21 @@ def test_iterables(self):
         assert sorted(iterlistvalues(ab)) == [['value_a'], ['value_b']]
         assert sorted(iterkeys(ab)) == ["key_a", "key_b"]
 
+    def test_get_description(self):
+        data = datastructures.OrderedMultiDict()
+
+        with pytest.raises(BadRequestKeyError) as exc_info:
+            data['baz']
+
+        assert 'baz' in exc_info.value.get_description()
+
+        with pytest.raises(BadRequestKeyError) as exc_info:
+            data.pop('baz')
+
+        assert 'baz' in exc_info.value.get_description()
+        exc_info.value.args = ()
+        assert 'baz' not in exc_info.value.get_description()
+
 
 class TestTypeConversionDict(object):
     storage_class = datastructures.TypeConversionDict