Repository: falconry/falcon

Issue:
Implement FloatConverter
Implement `FloatConverter` along the lines of [`IntConverter`](https://falcon.readthedocs.io/en/stable/api/routing.html#falcon.routing.IntConverter). Draw inspiration from `IntConverter`, or even find an efficient way to share code between the two!

Add the new converter to the list of [Built-in Converters](https://falcon.readthedocs.io/en/stable/api/routing.html#built-in-converters) under the `float` identifier.

Open questions: should we support converting `nan`, `inf` & `-inf` from path?


Discussion:

Resolving patch:
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
--- a/falcon/routing/converters.py
+++ b/falcon/routing/converters.py
@@ -14,6 +14,7 @@
 
 import abc
 from datetime import datetime
+from math import isfinite
 import uuid
 
 __all__ = (
@@ -21,6 +22,7 @@
     'IntConverter',
     'DateTimeConverter',
     'UUIDConverter',
+    'FloatConverter',
 )
 
 
@@ -76,7 +78,6 @@ class IntConverter(BaseConverter):
     def __init__(self, num_digits=None, min=None, max=None):
         if num_digits is not None and num_digits < 1:
             raise ValueError('num_digits must be at least 1')
-
         self._num_digits = num_digits
         self._min = min
         self._max = max
@@ -86,10 +87,10 @@ def convert(self, value):
             return None
 
         # NOTE(kgriffs): int() will accept numbers with preceding or
-        # trailing whitespace, so we need to do our own check. Using
-        # strip() is faster than either a regex or a series of or'd
-        # membership checks via "in", esp. as the length of contiguous
-        # numbers in the value grows.
+        #   trailing whitespace, so we need to do our own check. Using
+        #   strip() is faster than either a regex or a series of or'd
+        #   membership checks via "in", esp. as the length of contiguous
+        #   numbers in the value grows.
         if value.strip() != value:
             return None
 
@@ -98,15 +99,52 @@ def convert(self, value):
         except ValueError:
             return None
 
+        return self._validate_min_max_value(value)
+
+    def _validate_min_max_value(self, value):
         if self._min is not None and value < self._min:
             return None
-
         if self._max is not None and value > self._max:
             return None
 
         return value
 
 
+class FloatConverter(IntConverter):
+    """Converts a field value to an float.
+
+    Identifier: `float`
+    Keyword Args:
+        min (float): Reject the value if it is less than this number.
+        max (float): Reject the value if it is greater than this number.
+        finite (bool) : Determines whether or not to only match ordinary
+            finite numbers (default: ``True``). Set to ``False`` to match
+            nan, inf, and -inf in addition to finite numbers.
+    """
+
+    __slots__ = '_finite'
+
+    def __init__(self, min: float = None, max: float = None, finite: bool = True):
+        self._min = min
+        self._max = max
+        self._finite = finite if finite is not None else True
+
+    def convert(self, value: str):
+        if value.strip() != value:
+            return None
+
+        try:
+            value = float(value)
+
+            if self._finite and not isfinite(value):
+                return None
+
+        except ValueError:
+            return None
+
+        return self._validate_min_max_value(value)
+
+
 class DateTimeConverter(BaseConverter):
     """Converts a field value to a datetime.
 
@@ -176,5 +214,6 @@ def convert(self, value):
     ('int', IntConverter),
     ('dt', DateTimeConverter),
     ('uuid', UUIDConverter),
+    ('float', FloatConverter),
     ('path', PathConverter),
 )


Test patch:
diff --git a/tests/test_uri_converters.py b/tests/test_uri_converters.py
--- a/tests/test_uri_converters.py
+++ b/tests/test_uri_converters.py
@@ -1,4 +1,5 @@
 from datetime import datetime
+import math
 import string
 import uuid
 
@@ -64,6 +65,65 @@ def test_int_converter_invalid_config(num_digits):
         converters.IntConverter(num_digits)
 
 
+@pytest.mark.parametrize(
+    'value, min, max, expected',
+    [
+        ('123', None, None, 123.0),
+        ('01', None, None, 1.0),
+        ('001', None, None, 1.0),
+        ('0', None, None, 0.0),
+        ('00', None, None, 00.0),
+        ('1', None, None, 1.0),
+        ('12', 2, None, 12.0),
+        ('1', 1, 1, 1.0),
+        ('12', 1, 20, 12.0),
+        ('12', 2, 10, None),
+        ('1', 2, 13, None),
+        ('-1', 2, 13, None),
+        ('-1', -2, 10, -1.0),
+        ('1.4', 1, 10, 1.4),
+        ('inf', 1, 100, None),
+        ('-inf', 1, 1000, None),
+        ('nan', 1, 1000, None),
+        ('1.5e100', 0, 1, None),
+        ('0.5e1', 0, 10, 5.0),
+        ('-1.6e1', -50, 50, -16.0),
+        ('1.5e2', 0, 500, 150),
+        ('-1.6e10', -1.7e10, 1.0e10, -16000000000.0),
+    ],
+)
+def test_float_converter(value, min, max, expected):
+    c = converters.FloatConverter(min, max)
+    assert c.convert(value) == expected
+
+
+@pytest.mark.parametrize(
+    'value', ['nan', 'NaN', 'NAN', 'nAn', '-inf', 'inf', '-INF', 'INF']
+)
+def test_float_converter_non_finite_allowed(value):
+    c = converters.FloatConverter(finite=False)
+    assert not math.isfinite(c.convert(value))
+
+
+@pytest.mark.parametrize('finite', [None, True])
+def test_float_converter_non_finite_disallowed(finite):
+    c = converters.FloatConverter(finite=finite)
+    assert c.convert('nan') is None
+
+
+@pytest.mark.parametrize(
+    'value',
+    (
+        ['0x0F', 'something', '', ' ']
+        + ['123' + w for w in string.whitespace]
+        + [w + '123' for w in string.whitespace]
+    ),
+)
+def test_float_converter_malformed(value):
+    c = converters.FloatConverter()
+    assert c.convert(value) is None
+
+
 @pytest.mark.parametrize(
     'value, format_string, expected',
     [
diff --git a/tests/test_uri_templates.py b/tests/test_uri_templates.py
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -6,6 +6,7 @@
 """
 
 from datetime import datetime
+import math
 import uuid
 
 import pytest
@@ -214,6 +215,49 @@ def test_int_converter(client, uri_template):
     assert resource1.req.path == '/123'
 
 
+@pytest.mark.parametrize('id_value', [2, 2.1, 1.9])
+@pytest.mark.parametrize(
+    'uri_template,',
+    [
+        '/{id:float}',
+        '/{id:float(1)}',
+        '/{id:float(min=1.9)}',
+        '/{id:float(min=1.8, max=3)}',
+    ],
+)
+def test_float_converter(client, uri_template, id_value):
+    resource1 = IDResource()
+    client.app.add_route(uri_template, resource1)
+
+    result = client.simulate_get('/{0}'.format(id_value))
+
+    assert result.status_code == 200
+    assert resource1.called
+    assert resource1.id == id_value
+    assert resource1.req.path == '/{0}'.format(id_value)
+
+
+@pytest.mark.parametrize('value', ['nan', '-inf', 'inf'])
+def test_float_converter_non_finite_allowed(value, client):
+    resource1 = IDResource()
+    client.app.add_route('/{id:float(finite=False)}', resource1)
+
+    result = client.simulate_get('/' + value)
+
+    assert result.status_code == 200
+    assert resource1.called
+    assert not math.isfinite(resource1.id)
+
+
+def test_float_converter_non_finite_disallowed(client):
+    resource1 = IDResource()
+    client.app.add_route('/{id:float}', resource1)
+
+    result = client.simulate_get('/NaN')
+    assert result.status_code == 404
+    assert not resource1.called
+
+
 @pytest.mark.parametrize(
     'uri_template,',
     [