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:
meetshah133: Hi @vytas7, can you please help by reviewing the code changes ?
I have tested the FloatConverter and IntConverter class they seems working fine,
Will be happy to get more inputs from you and contribute to the project.
vytas7: Hi @meetshah133, and thanks for this PR!
Unfortunately I won't have the bandwidth for this today, but will try to look at your code it in the beginning of the next week. 
It also possible that @CaselIT, @kgriffs or other maintainers beat me to it :calendar:

To start with, you could address the CI failures (see **"Some checks were not successful"** below) to get the things moving faster :arrow_down:
(It's lame that GitHub doesn't run Actions for first-time contributors, but I can try to at least push the button when I notice that you have committed new stuff.)
meetshah133: Thanks @vytas7 , @CaselIT  , @myusko for your valuable inputs.
I will work on those items suggested by you all  and submit a new commit to this repository.
meetshah133: Hi @vytas7 , have implemented all the suggestions, need some guidance on "tox -e black" test failure 
vytas7: Luckily, that's the easiest one ;) Just install the latest `black` version into your environment, and run it:
```
$ pip install -U black
$ black .
```
meetshah133: Thanks @vytas7 finally all checks passed 
meetshah133: @CaselIT and @vytas7  thanks for your inputs, I have created a new commit based on your inputs.
vytas7: Hi again @meetshah133, just checking if you are still working on these changes?
meetshah133: > Hi again @meetshah133, just checking if you are still working on these changes?

Hi @vytas7, was not well for past few days.
I am working on the changes suggested by you will soon commit the changes.
Thanks. 
vytas7: Awesome, thanks for the update!
meetshah133: Hi @vytas7 , I have done the commit there are 3 test failing, but I am unable to understand why.
Could you please assist ?
vytas7: @meetshah133 could you please update your branch with the latest changes from `master` instead of force-pushing over?
kgriffs: OK folks, in the interest of getting this over the finish line sooner rather than later, I took the liberty of trying to address the unresolved feedback. Please take another look. Thanks!

Patch:
diff --git a/docs/_newsfragments/2022.newandimproved.rst b/docs/_newsfragments/2022.newandimproved.rst
new file mode 100644
index 0000000..f0448fe
--- /dev/null
+++ b/docs/_newsfragments/2022.newandimproved.rst
@@ -0,0 +1,1 @@
+:class:`FloatConverter`:Modified existing IntConverter class and added FloatConverter class to convert string to float at runtime.
\ No newline at end of file
diff --git a/docs/api/routing.rst b/docs/api/routing.rst
index f38de60..d9cd6c4 100644
--- a/docs/api/routing.rst
+++ b/docs/api/routing.rst
@@ -295,6 +295,7 @@ Built-in Converters
  ``int``       :class:`~.IntConverter`            ``/teams/{tid:int(8)}``
  ``uuid``      :class:`~.UUIDConverter`           ``/diff/{left:uuid}...{right:uuid}``
  ``dt``        :class:`~.DateTimeConverter`       ``/logs/{day:dt("%Y-%m-%d")}``
+ ``float``     :class:`~.FloatConverter`          ``/python/versions/{version:float(min=3.7)}``
  ``path``      :class:`~.PathConverter`           ``/prefix/{other:path}``
 ============  =================================  ==================================================================
 
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
index 26c62e8..71b2782 100644
--- 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 @@ __all__ = (
     '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 @@ class IntConverter(BaseConverter):
             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 @@ class IntConverter(BaseConverter):
         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 @@ BUILTIN = (
     ('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
index a1a0791..1a5c579 100644
--- 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
index 18d07da..7f6c72b 100644
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -6,6 +6,7 @@ path via simulate_get(), vs. probing the router directly.
 """
 
 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,',
     [