Repository: falconry/falcon
Pull request #1079 (2017-07-04 00:24:24)

feat(routing): Add DateTimeConverter

codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=h1) Report
> Merging [#1079](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/25e0c3873b24dda74e85387457e0e3829cbee7bf?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1079/graphs/tree.svg?src=pr&width=650&height=150&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1079   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          38      38           
  Lines        2327    2338   +11     
  Branches      331     331           
======================================
+ Hits         2327    2338   +11
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/routing/converters.py](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvY29udmVydGVycy5weQ==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=footer). Last update [25e0c38...2615f0b](https://codecov.io/gh/falconry/falcon/pull/1079?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/falcon/routing/converters.py b/falcon/routing/converters.py
index 51c6b27..e6934cb 100644
--- a/falcon/routing/converters.py
+++ b/falcon/routing/converters.py
@@ -12,6 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from datetime import datetime
+
+
+# PERF(kgriffs): Avoid an extra namespace lookup when using this function
+strptime = datetime.strptime
+
 
 class IntConverter(object):
     """Converts a field value to an int.
@@ -59,6 +65,28 @@ class IntConverter(object):
         return value
 
 
+class DateTimeConverter(object):
+    """Converts a field value to a datetime.
+
+    Keyword Args:
+        format_string (str): String used to parse the param value
+            into a datetime. Any format recognized by strptime() is
+            supported (default ``'%Y-%m-%dT%H:%M:%SZ'``).
+    """
+
+    __slots__ = ('_format_string',)
+
+    def __init__(self, format_string='%Y-%m-%dT%H:%M:%SZ'):
+        self._format_string = format_string
+
+    def convert(self, fragment):
+        try:
+            return strptime(fragment, self._format_string)
+        except ValueError:
+            return None
+
+
 BUILTIN = (
     ('int', IntConverter),
+    ('dt', DateTimeConverter),
 )


Test patch:
diff --git a/tests/test_uri_converters.py b/tests/test_uri_converters.py
index 66a6f71..536512a 100644
--- a/tests/test_uri_converters.py
+++ b/tests/test_uri_converters.py
@@ -1,3 +1,4 @@
+from datetime import datetime
 import string
 
 import pytest
@@ -5,7 +6,7 @@ import pytest
 from falcon.routing import converters
 
 
-@pytest.mark.parametrize('segment,num_digits,min,max,expected', [
+@pytest.mark.parametrize('fragment, num_digits, min, max, expected', [
     ('123', None, None, None, 123),
     ('01', None, None, None, 1),
     ('001', None, None, None, 1),
@@ -33,22 +34,43 @@ from falcon.routing import converters
     ('12', 2, 13, 12, None),
     ('12', 2, 13, 13, None),
 ])
-def test_int_filter(segment, num_digits, min, max, expected):
+def test_int_converter(fragment, num_digits, min, max, expected):
     c = converters.IntConverter(num_digits, min, max)
-    assert c.convert(segment) == expected
+    assert c.convert(fragment) == expected
 
 
-@pytest.mark.parametrize('segment', (
+@pytest.mark.parametrize('fragment', (
     ['0x0F', 'something', '', ' '] +
     ['123' + w for w in string.whitespace] +
     [w + '123' for w in string.whitespace]
 ))
-def test_int_filter_malformed(segment):
+def test_int_converter_malformed(fragment):
     c = converters.IntConverter()
-    assert c.convert(segment) is None
+    assert c.convert(fragment) is None
 
 
 @pytest.mark.parametrize('num_digits', [0, -1, -10])
-def test_int_filter_invalid_config(num_digits):
+def test_int_converter_invalid_config(num_digits):
     with pytest.raises(ValueError):
         converters.IntConverter(num_digits)
+
+
+@pytest.mark.parametrize('fragment, format_string, expected', [
+    ('07-03-17', '%m-%d-%y', datetime(2017, 7, 3)),
+    ('07-03-17 ', '%m-%d-%y ', datetime(2017, 7, 3)),
+    ('2017-07-03T14:30:01Z', '%Y-%m-%dT%H:%M:%SZ', datetime(2017, 7, 3, 14, 30, 1)),
+    ('2017-07-03T14:30:01', '%Y-%m-%dT%H:%M:%S', datetime(2017, 7, 3, 14, 30, 1)),
+    ('2017-07-03T14:30:01', '%Y-%m-%dT%H:%M:%SZ', None),
+    ('2017_19', '%Y_%H', datetime(2017, 1, 1, 19, 0)),
+    ('07-03-17 ', '%m-%d-%y', None),
+    (' 07-03-17', '%m-%d-%y', None),
+    ('07 -03-17', '%m-%d-%y', None),
+])
+def test_datetime_converter(fragment, format_string, expected):
+    c = converters.DateTimeConverter(format_string)
+    assert c.convert(fragment) == expected
+
+
+def test_datetime_converter_default_format():
+    c = converters.DateTimeConverter()
+    assert c.convert('2017-07-03T14:30:01Z') == datetime(2017, 7, 3, 14, 30, 1)
diff --git a/tests/test_uri_templates.py b/tests/test_uri_templates.py
index f18c229..e330898 100644
--- a/tests/test_uri_templates.py
+++ b/tests/test_uri_templates.py
@@ -5,6 +5,8 @@ a collection of sanity-checks that exercise the full framework code
 path via simulate_get(), vs. probing the router directly.
 """
 
+from datetime import datetime
+
 import pytest
 import six
 
@@ -137,7 +139,7 @@ def test_single(client, resource, field_name):
     '/{id:int(min=123)}',
     '/{id:int(min=123, max=123)}',
 ])
-def test_converter(client, uri_template):
+def test_int_converter(client, uri_template):
     resource1 = IDResource()
     client.app.add_route(uri_template, resource1)
 
@@ -154,7 +156,7 @@ def test_converter(client, uri_template):
     '/{id:int(min=124)}',
     '/{id:int(num_digits=3, max=100)}',
 ])
-def test_converter_rejections(client, uri_template):
+def test_int_converter_rejections(client, uri_template):
     resource1 = IDResource()
     client.app.add_route(uri_template, resource1)
 
@@ -164,6 +166,43 @@ def test_converter_rejections(client, uri_template):
     assert not resource1.called
 
 
+@pytest.mark.parametrize('uri_template, path, dt_expected', [
+    (
+        '/{start_year:int}-to-{timestamp:dt}',
+        '/1961-to-1969-07-21T02:56:00Z',
+        datetime(1969, 7, 21, 2, 56, 0)
+    ),
+    (
+        '/{start_year:int}-to-{timestamp:dt("%Y-%m-%d")}',
+        '/1961-to-1969-07-21',
+        datetime(1969, 7, 21)
+    ),
+    (
+        '/{start_year:int}/{timestamp:dt("%Y-%m-%d %H:%M")}',
+        '/1961/1969-07-21 14:30',
+        datetime(1969, 7, 21, 14, 30)
+    ),
+    (
+        '/{start_year:int}-to-{timestamp:dt("%Y-%m")}',
+        '/1961-to-1969-07-21',
+        None
+    ),
+])
+def test_datetime_converter(client, resource, uri_template, path, dt_expected):
+    client.app.add_route(uri_template, resource)
+
+    result = client.simulate_get(path)
+
+    if dt_expected is None:
+        assert result.status_code == 404
+        assert not resource.called
+    else:
+        assert result.status_code == 200
+        assert resource.called
+        assert resource.captured_kwargs['start_year'] == 1961
+        assert resource.captured_kwargs['timestamp'] == dt_expected
+
+
 def test_converter_custom(client, resource):
     class SpamConverter(object):
         def convert(self, fragment):