Repository: pallets/werkzeug

Issue:
The float converter does not recognise negative numbers
I’m writing an application where the URLs contain floating point numbers that represent latitudes and longitudes. I was surprised to find I could not use the werkzeug.routing.FloatConverter for this, since it does not recognise negative numbers.



Discussion:
That's intentional, but I don't see a reason for this behavior documented anywhere.

I'm also hitting this problem.
Is there any reason that -? couldn't be added to the front of the regex?

(I won't even mention problems with using float instead of Decimal....)
If someone wants this, make a pull request for it.
This needs to be discussed first, since it is backwards-incompatible.
@mitsuhiko's decision.

On Thu, Oct 19, 2017 at 06:14:23PM +0000, David Lord wrote:
> If someone wants this, make a pull request for it.
> 
> -- 
> You are receiving this because you commented.
> Reply to this email directly or view it on GitHub:
> https://github.com/pallets/werkzeug/issues/729#issuecomment-337992353

the introduction of a new signedfloat converter would be backward compatible
I don't find the backwards incompatibility particularly significant here. Add a `signed=True` arg to the converters. Add a note to the changelog "The `int` and `float` converters now recognizes negative value. Pass `signed=False` to keep the previous behavior and only match positive values."
minor details are significant for those whose code they break ^^
the default at introduction time should be none and a warning that around a certain point in time the default will be set
Parametrizing converters isn't even that straightforward, so I think having the old float and a new signed-float (sfloat?) type would make more sense.
This is also equally true for the integer (`int`) converter.
Hey @davidism -- Thanks for the redirect here and sorry to kick a hornet's nest.  I skimmed over the whole issue thread before I posted and it def seemed like there were reasons for why it was impossible to modify the existing converters to also support negative numbers though I'll confess I wasn't sure what they were.  

In terms of moving forward constructively, what I'm (still!) happy to do is create a pull request defining two new converters (one for floats and one for ints) that use different negative-number-supporting regexes.  That seems to be a nice way to preserve the legacy functionality and help out new users of Werkzeug, no?

Resolving patch:
diff --git a/werkzeug/routing.py b/werkzeug/routing.py
--- a/werkzeug/routing.py
+++ b/werkzeug/routing.py
@@ -1307,11 +1307,14 @@ class NumberConverter(BaseConverter):
     """
     weight = 50
 
-    def __init__(self, map, fixed_digits=0, min=None, max=None):
+    def __init__(self, map, fixed_digits=0, min=None, max=None, signed=False):
+        if signed:
+            self.regex = self.signed_regex
         BaseConverter.__init__(self, map)
         self.fixed_digits = fixed_digits
         self.min = min
         self.max = max
+        self.signed = signed
 
     def to_python(self, value):
         if (self.fixed_digits and len(value) != self.fixed_digits):
@@ -1328,22 +1331,32 @@ def to_url(self, value):
             value = ('%%0%sd' % self.fixed_digits) % value
         return str(value)
 
+    @property
+    def signed_regex(self):
+        return r"-?" + self.regex
+
 
 class IntegerConverter(NumberConverter):
 
     """This converter only accepts integer values::
 
-        Rule('/page/<int:page>')
+        Rule("/page/<int:page>")
 
-    This converter does not support negative values.
+    By default it only accepts unsigned, positive values. The ``signed``
+    parameter will enable signed, negative values. ::
 
-    :param map: the :class:`Map`.
-    :param fixed_digits: the number of fixed digits in the URL.  If you set
-                         this to ``4`` for example, the application will
-                         only match if the url looks like ``/0001/``.  The
-                         default is variable length.
-    :param min: the minimal value.
-    :param max: the maximal value.
+        Rule("/page/<int(signed=True):page>")
+
+    :param map: The :class:`Map`.
+    :param fixed_digits: The number of fixed digits in the URL. If you
+        set this to ``4`` for example, the rule will only match if the
+        URL looks like ``/0001/``. The default is variable length.
+    :param min: The minimal value.
+    :param max: The maximal value.
+    :param signed: Allow signed (negative) values.
+
+    .. versionadded:: 0.15
+        The ``signed`` parameter.
     """
     regex = r'\d+'
     num_convert = int
@@ -1353,19 +1366,26 @@ class FloatConverter(NumberConverter):
 
     """This converter only accepts floating point values::
 
-        Rule('/probability/<float:probability>')
+        Rule("/probability/<float:probability>")
 
-    This converter does not support negative values.
+    By default it only accepts unsigned, positive values. The ``signed``
+    parameter will enable signed, negative values. ::
 
-    :param map: the :class:`Map`.
-    :param min: the minimal value.
-    :param max: the maximal value.
+        Rule("/offset/<float(signed=True):offset>")
+
+    :param map: The :class:`Map`.
+    :param min: The minimal value.
+    :param max: The maximal value.
+    :param signed: Allow signed (negative) values.
+
+    .. versionadded:: 0.15
+        The ``signed`` parameter.
     """
     regex = r'\d+\.\d+'
     num_convert = float
 
-    def __init__(self, map, min=None, max=None):
-        NumberConverter.__init__(self, map, 0, min, max)
+    def __init__(self, map, min=None, max=None, signed=False):
+        NumberConverter.__init__(self, map, min=min, max=max, signed=signed)
 
 
 class UUIDConverter(BaseConverter):


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -201,6 +201,27 @@ def test_defaults():
     assert adapter.build('foo', {'page': 2}) == '/foo/2'
 
 
+def test_negative():
+    map = r.Map([
+        r.Rule('/foos/<int(signed=True):page>', endpoint='foos'),
+        r.Rule('/bars/<float(signed=True):page>', endpoint='bars'),
+        r.Rule('/foo/<int:page>', endpoint='foo'),
+        r.Rule('/bar/<float:page>', endpoint='bar')
+    ])
+    adapter = map.bind('example.org', '/')
+
+    assert adapter.match('/foos/-2') == ('foos', {'page': -2})
+    assert adapter.match('/foos/-50') == ('foos', {'page': -50})
+    assert adapter.match('/bars/-2.0') == ('bars', {'page': -2.0})
+    assert adapter.match('/bars/-0.185') == ('bars', {'page': -0.185})
+
+    # Make sure signed values are rejected in unsigned mode
+    pytest.raises(r.NotFound, lambda: adapter.match('/foo/-2'))
+    pytest.raises(r.NotFound, lambda: adapter.match('/foo/-50'))
+    pytest.raises(r.NotFound, lambda: adapter.match('/bar/-0.185'))
+    pytest.raises(r.NotFound, lambda: adapter.match('/bar/-2.0'))
+
+
 def test_greedy():
     map = r.Map([
         r.Rule('/foo', endpoint='foo'),