Repository: pallets/werkzeug

Issue:
BuildErrors could be more informative
The exceptions we currently have are looking like this:

```
BuildError: ('users.view_user', {'view': 'simple'}, 'GET')
```

What I would like to see instead:

```
BuildError: No route found for 'users.view_user' with parameters {'view': 'simple'}. Did you forget to add parameter "id"?
```

or 

```
BuildError: No route found for 'users.view_user' with parameters {'view': 'simple'}. Did you mean 'user.view_user' instead?
```

I'm not saying the exception we currently have are technically wrong but I'm arguing it's bad user experience. 



Discussion:
I agree that the current errors are bad, but neither of your suggestions are easy to implement. Especially the latter, it probably requires an algorithm for calculating the Levenshtein distance?

the first one seems like a good easy task for someone new,
the second one is something extended for a interested 3rd party

Python has a great sequence similarity algorithm built-in, e.g.

``` python
import difflib
print difflib.SequenceMatcher(None, 'users.view_user', 'user.view_user').ratio() # 0.965517241379
```

Ok, that's interesting.


Resolving patch:
diff --git a/werkzeug/routing.py b/werkzeug/routing.py
--- a/werkzeug/routing.py
+++ b/werkzeug/routing.py
@@ -95,6 +95,7 @@
     :copyright: (c) 2014 by the Werkzeug Team, see AUTHORS for more details.
     :license: BSD, see LICENSE for more details.
 """
+import difflib
 import re
 import uuid
 import posixpath
@@ -248,11 +249,55 @@ class BuildError(RoutingException, LookupError):
     values provided.
     """
 
-    def __init__(self, endpoint, values, method):
+    def __init__(self, endpoint, values, method, adapter=None):
         LookupError.__init__(self, endpoint, values, method)
         self.endpoint = endpoint
         self.values = values
         self.method = method
+        self.suggested = self.closest_rule(adapter)
+
+    def closest_rule(self, adapter):
+        def score_rule(rule):
+            return sum([
+                0.98 * difflib.SequenceMatcher(
+                    None, rule.endpoint, self.endpoint
+                ).ratio(),
+                0.01 * bool(set(self.values or ()).issubset(rule.arguments)),
+                0.01 * bool(rule.methods and self.method in rule.methods)
+            ])
+
+        if adapter and adapter.map._rules:
+            return max(adapter.map._rules, key=score_rule)
+        else:
+            return None
+
+    def __str__(self):
+        message = []
+        message.append("Could not build url for endpoint %r" % self.endpoint)
+        if self.method:
+            message.append(" (%r)" % self.method)
+        if self.values:
+            message.append(" with values %r" % sorted(self.values.keys()))
+        message.append(".")
+        if self.suggested:
+            if self.endpoint == self.suggested.endpoint:
+                if self.method not in self.suggested.methods:
+                    message.append(" Did you mean to use methods %r?" % sorted(
+                        self.suggested.methods
+                    ))
+                missing_values = self.suggested.arguments.union(
+                    set(self.suggested.defaults or ())
+                ) - set(self.values.keys())
+                if missing_values:
+                    message.append(
+                        " Did you forget to specify values %r?" %
+                        sorted(missing_values)
+                    )
+            else:
+                message.append(
+                    " Did you mean %r instead?" % self.suggested.endpoint
+                )
+        return "".join(message)
 
 
 class ValidationError(ValueError):
@@ -1674,7 +1719,7 @@ def build(self, endpoint, values=None, method=None, force_external=False,
 
         rv = self._partial_build(endpoint, values, method, append_unknown)
         if rv is None:
-            raise BuildError(endpoint, values, method)
+            raise BuildError(endpoint, values, method, self)
         domain_part, path = rv
 
         host = self.get_host(domain_part)


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -720,3 +720,78 @@ def __init__(self, string=None, custom=None, *args, **kwargs):
         assert rule1.rule == rule2.rule
     except TypeError as e:  # raised without fix in PR #675
         raise e
+
+def test_finding_closest_match_by_endpoint():
+    m = r.Map([
+        r.Rule(u'/foo/', endpoint='users.here'),
+        r.Rule(u'/wat/', endpoint='admin.users'),
+        r.Rule(u'/woop', endpoint='foo.users'),
+    ])
+    adapter = m.bind('example.com')
+    assert r.BuildError('admin.user', None, None, adapter).suggested.endpoint \
+           == 'admin.users'
+
+def test_finding_closest_match_by_values():
+    rule_id = r.Rule(u'/user/id/<id>/', endpoint='users')
+    rule_slug = r.Rule(u'/user/<slug>/', endpoint='users')
+    rule_random = r.Rule(u'/user/emails/<email>/', endpoint='users')
+    m = r.Map([rule_id, rule_slug, rule_random])
+    adapter = m.bind('example.com')
+    assert r.BuildError('x', {'slug': ''}, None, adapter).suggested == \
+           rule_slug
+
+def test_finding_closest_match_by_method():
+    post = r.Rule(u'/post/', endpoint='foobar', methods=['POST'])
+    get = r.Rule(u'/get/', endpoint='foobar', methods=['GET'])
+    put = r.Rule(u'/put/', endpoint='foobar', methods=['PUT'])
+    m = r.Map([post, get, put])
+    adapter = m.bind('example.com')
+    assert r.BuildError('invalid', {}, 'POST', adapter).suggested == post
+    assert r.BuildError('invalid', {}, 'GET', adapter).suggested == get
+    assert r.BuildError('invalid', {}, 'PUT', adapter).suggested == put
+
+def test_finding_closest_match_when_none_exist():
+    m = r.Map([])
+    assert not r.BuildError('invalid', {}, None, m.bind('test.com')).suggested
+
+def test_error_message_without_suggested_rule():
+    m = r.Map([
+        r.Rule(u'/foo/', endpoint='world', methods=['GET']),
+    ])
+    adapter = m.bind('example.com')
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('urks')
+    assert str(excinfo.value).startswith(
+        "Could not build url for endpoint 'urks'."
+    )
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('world', method='POST')
+    assert str(excinfo.value).startswith(
+        "Could not build url for endpoint 'world' ('POST')."
+    )
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('urks', values={'user_id': 5})
+    assert str(excinfo.value).startswith(
+        "Could not build url for endpoint 'urks' with values ['user_id']."
+    )
+
+def test_error_message_suggestion():
+    m = r.Map([
+        r.Rule(u'/foo/<id>/', endpoint='world', methods=['GET']),
+    ])
+    adapter = m.bind('example.com')
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('helloworld')
+    assert "Did you mean 'world' instead?" in str(excinfo.value)
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('world')
+    assert "Did you forget to specify values ['id']?" in str(excinfo.value)
+
+    with pytest.raises(r.BuildError) as excinfo:
+        adapter.build('world', {'id': 2}, method='POST')
+    assert "Did you mean to use methods ['GET', 'HEAD']?" in str(excinfo.value)