Repository: coverahealth/dataspec

Issue:
Support arbitrary ISO timestamps using dateutil
Currently, the string formatters for ISO date, datetimes, and times actually come from Python's `fromisoformat` staticmethods on each of those respective classes (added in Python 3.7). Those methods do not parse arbitrary ISO 8601 formatted strings and therefore are relatively unforgiving with their formats. If we add [`dateutil`](https://github.com/dateutil/dateutil/), we can support parsing these ISO 8601 stamps arbitrarily.

Ideally, I'd like to allow `dateutil` to be specified as an install extra, so users that do not wish to install it can opt-out.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f7b79b..58388aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Allow type-checking specs to be created by passing a type directly to `s` (#12)
 - Add email address string format (#6)
 - Add URL string format factory `s.url` (#16)
+- Add Python `dateutil` support for parsing dates (#5)
 
 ### Fixed
 - Guard against `inspect.signature` failures with Python builtins (#18)
diff --git a/Makefile b/Makefile
index 48451e6..26776a6 100644
--- a/Makefile
+++ b/Makefile
@@ -23,5 +23,5 @@ format:
 
 .PHONY: test
 test:
-	@rm .coverage*
-	@tox -e py36,py37,py38,format,mypy,lint -p 4
+	@rm -f .coverage*
+	@TOX_SKIP_ENV=coverage tox -p 4
diff --git a/requirements.txt b/requirements.txt
index 53e9897..75cc9c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,2 @@
 attrs>=19.1.0
+python-dateutil
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 25c9e91..69154c0 100644
--- a/setup.py
+++ b/setup.py
@@ -22,7 +22,7 @@ VERSION = None
 
 REQUIRED = ["attrs"]
 
-EXTRAS = {}
+EXTRAS = {"dates": ["python-dateutil"]}
 
 # Copied from the excellent https://github.com/kennethreitz/setup.py
 
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index 5673c9e..8ffe3e6 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -121,5 +121,13 @@ class SpecAPI:
     fdef = staticmethod(_fdef)
     opt = staticmethod(opt_key)
 
+    # Conditionally available spec factories
+    try:
+        from dataspec.factories import datetime_str_spec
+    except ImportError:
+        pass
+    else:
+        inst_str = staticmethod(datetime_str_spec)
+
 
 s = SpecAPI()
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 3092dac..4f27d06 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -262,6 +262,81 @@ datetime_spec = _make_datetime_spec_factory(datetime)
 date_spec = _make_datetime_spec_factory(date)
 time_spec = _make_datetime_spec_factory(time)
 
+try:
+    from dateutil.parser import parse as parse_date, isoparse as parse_isodate
+except ImportError:
+    pass
+else:
+
+    def datetime_str_spec(  # pylint: disable=too-many-arguments
+        tag: Optional[Tag] = None,
+        iso_only: bool = False,
+        before: Optional[datetime] = None,
+        after: Optional[datetime] = None,
+        is_aware: Optional[bool] = None,
+        conformer: Optional[Conformer] = None,
+    ) -> Spec:
+        """
+        Return a Spec that validates strings containing date/time strings in
+        most common formats.
+
+        The resulting Spec will validate that the input value is a string which
+        contains a date/time using :py:func:`dateutil.parser.parse`. If the input
+        value can be determined to contain a valid :py:class:`datetime.datetime`
+        instance, it will be validated against a datetime Spec as by a standard
+        ``dataspec`` datetime Spec using the keyword options below.
+
+        :py:func:`dateutil.parser.parse` cannot produce :py:class:`datetime.time`
+        or :py:class:`datetime.date` instances directly, so this method will only
+        produce :py:func:`datetime.datetime` instances even if the input string
+        contains only a valid time or date, but not both.
+
+        If ``iso_only`` keyword argument is ``True``, restrict the set of allowed
+        input values to strings which contain ISO 8601 formatted strings. This is
+        accomplished using :py:func:`dateutil.parser.isoparse`, which does not
+        guarantee strict adherence to the ISO 8601 standard, but accepts a wider
+        range of valid ISO 8601 strings than Python 3.7+'s
+        :py:func:`datetime.datetime.fromisoformat` function.
+
+        :param tag: an optional tag for the resulting spec
+        :param iso_only: if True, restrict the set of allowed date strings to those
+            formatted as ISO 8601 datetime strings; default is False
+        :param before: if specified, a datetime that specifies the latest instant
+            this Spec will validate
+        :param after: if specified, a datetime that specifies the earliest instant
+            this Spec will validate
+        :param bool is_aware: if specified, indicate whether the Spec will validate
+            either aware or naive :py:class:`datetime.datetime` instances.
+        :param conformer: an optional conformer for the value; if one is not provided
+            :py:func:`dateutil.parser.parse` will be used
+        :return: a Spec which validates strings containing date/time strings
+        """
+
+        tag = tag or "datetime_str"
+
+        @pred_to_validator(f"Value '{{value}}' is not type 'str'", complement=True)
+        def is_str(x: Any) -> bool:
+            return isinstance(x, str)
+
+        dt_spec = datetime_spec(before=before, after=after, is_aware=is_aware)
+        parse_date_str = parse_isodate if iso_only else parse_date
+
+        def str_contains_datetime(s: str) -> Iterator[ErrorDetails]:
+            try:
+                parsed_dt = parse_date_str(s)  # type: ignore
+            except (OverflowError, ValueError):
+                yield ErrorDetails(
+                    message=f"String '{s}' does not contain a datetime",
+                    pred=str_contains_datetime,
+                    value=s,
+                )
+            else:
+                yield from dt_spec.validate(parsed_dt)
+
+        return ValidatorSpec.from_validators(
+            tag, is_str, str_contains_datetime, conformer=conformer or parse_date
+        )
+
 
 def nilable_spec(
     *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
diff --git a/tox.ini b/tox.ini
index 4617193..d4d31c7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,10 @@
 [tox]
-envlist = py36,py37,py38,coverage,format,mypy,lint,safety
+envlist = {py36,py37,py38}{-dateutil,},coverage,format,mypy,lint,safety
 
 [testenv]
 deps =
     coverage
+    dateutil: python-dateutil
     pytest
 commands =
     coverage run \


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index dc141ce..1f5fd9a 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -7,6 +7,11 @@ import pytest
 
 from dataspec import INVALID, Spec, s
 
+try:
+    from dateutil.parser import parse as parse_date
+except ImportError:
+    parse_date = None
+
 
 class TestAllSpecValidation:
     @pytest.fixture
@@ -369,7 +374,7 @@ class TestDateSpecValidation:
             assert not after_spec.is_valid(v)
 
     class TestIsAwareSpec:
-        def test_aware_spec(self) -> Spec:
+        def test_aware_spec(self):
             s.date(is_aware=False)
 
             with pytest.raises(TypeError):
@@ -466,6 +471,110 @@ class TestTimeSpecValidation:
             assert not aware_spec.is_valid(time())
 
 
+@pytest.mark.skipif(parse_date is None, reason="python-dateutil must be installed")
+class TestInstStringSpecValidation:
+    ISO_DATETIMES = [
+        ("2003-09-25T10:49:41", datetime(2003, 9, 25, 10, 49, 41)),
+        ("2003-09-25T10:49", datetime(2003, 9, 25, 10, 49)),
+        ("2003-09-25T10", datetime(2003, 9, 25, 10)),
+        ("2003-09-25", datetime(2003, 9, 25)),
+        ("20030925T104941", datetime(2003, 9, 25, 10, 49, 41)),
+        ("20030925T1049", datetime(2003, 9, 25, 10, 49, 0)),
+        ("20030925T10", datetime(2003, 9, 25, 10)),
+        ("20030925", datetime(2003, 9, 25)),
+        ("2003-09-25 10:49:41,502", datetime(2003, 9, 25, 10, 49, 41, 502000)),
+        ("19760704", datetime(1976, 7, 4)),
+        ("0099-01-01T00:00:00", datetime(99, 1, 1, 0, 0)),
+        ("0031-01-01T00:00:00", datetime(31, 1, 1, 0, 0)),
+        ("20080227T21:26:01.123456789", datetime(2008, 2, 27, 21, 26, 1, 123456)),
+        ("0003-03-04", datetime(3, 3, 4)),
+        ("950404 122212", datetime(1995, 4, 4, 12, 22, 12)),
+    ]
+    NON_ISO_DATETIMES = [
+        ("Thu Sep 25 10:36:28 2003", datetime(2003, 9, 25, 10, 36, 28)),
+        ("Thu Sep 25 2003", datetime(2003, 9, 25)),
+        ("199709020908", datetime(1997, 9, 2, 9, 8)),
+        ("19970902090807", datetime(1997, 9, 2, 9, 8, 7)),
+        ("09-25-2003", datetime(2003, 9, 25)),
+        ("25-09-2003", datetime(2003, 9, 25)),
+        ("10-09-2003", datetime(2003, 10, 9)),
+        ("10-09-03", datetime(2003, 10, 9)),
+        ("2003.09.25", datetime(2003, 9, 25)),
+        ("09.25.2003", datetime(2003, 9, 25)),
+        ("25.09.2003", datetime(2003, 9, 25)),
+        ("10.09.2003", datetime(2003, 10, 9)),
+        ("10.09.03", datetime(2003, 10, 9)),
+        ("2003/09/25", datetime(2003, 9, 25)),
+        ("09/25/2003", datetime(2003, 9, 25)),
+        ("25/09/2003", datetime(2003, 9, 25)),
+        ("10/09/2003", datetime(2003, 10, 9)),
+        ("10/09/03", datetime(2003, 10, 9)),
+        ("2003 09 25", datetime(2003, 9, 25)),
+        ("09 25 2003", datetime(2003, 9, 25)),
+        ("25 09 2003", datetime(2003, 9, 25)),
+        ("10 09 2003", datetime(2003, 10, 9)),
+        ("10 09 03", datetime(2003, 10, 9)),
+        ("25 09 03", datetime(2003, 9, 25)),
+        ("03 25 Sep", datetime(2003, 9, 25)),
+        ("25 03 Sep", datetime(2025, 9, 3)),
+        ("  July   4 ,  1976   12:01:02   am  ", datetime(1976, 7, 4, 0, 1, 2)),
+        ("Wed, July 10, '96", datetime(1996, 7, 10, 0, 0)),
+        ("1996.July.10 AD 12:08 PM", datetime(1996, 7, 10, 12, 8)),
+        ("July 4, 1976", datetime(1976, 7, 4)),
+        ("7 4 1976", datetime(1976, 7, 4)),
+        ("4 jul 1976", datetime(1976, 7, 4)),
+        ("4 Jul 1976", datetime(1976, 7, 4)),
+        ("7-4-76", datetime(1976, 7, 4)),
+        ("0:01:02 on July 4, 1976", datetime(1976, 7, 4, 0, 1, 2)),
+        ("July 4, 1976 12:01:02 am", datetime(1976, 7, 4, 0, 1, 2)),
+        ("Mon Jan  2 04:24:27 1995", datetime(1995, 1, 2, 4, 24, 27)),
+        ("04.04.95 00:22", datetime(1995, 4, 4, 0, 22)),
+        ("Jan 1 1999 11:23:34.578", datetime(1999, 1, 1, 11, 23, 34, 578000)),
+        ("3rd of May 2001", datetime(2001, 5, 3)),
+        ("5th of March 2001", datetime(2001, 3, 5)),
+        ("1st of May 2003", datetime(2003, 5, 1)),
+        ("13NOV2017", datetime(2017, 11, 13)),
+        ("December.0031.30", datetime(31, 12, 30)),
+    ]
+    ALL_DATETIMES = ISO_DATETIMES + NON_ISO_DATETIMES
+
+    @pytest.fixture
+    def inst_str_spec(self) -> Spec:
+        return s.inst_str()
+
+    @pytest.mark.parametrize("date_str,datetime_obj", ALL_DATETIMES)
+    def test_inst_str_validation(self, inst_str_spec: Spec, date_str, datetime_obj):
+        assert inst_str_spec.is_valid(date_str)
+        assert datetime_obj == inst_str_spec.conform(date_str)
+
+    @pytest.mark.parametrize(
+        "v", ["", "abcde", "Tue September 32 2019", 5, 3.14, None, {}, set(), []]
+    )
+    def test_inst_str_validation_failure(self, inst_str_spec: Spec, v):
+        assert not inst_str_spec.is_valid(v)
+        assert INVALID is inst_str_spec.conform(v)
+
+    @pytest.fixture
+    def iso_inst_str_spec(self) -> Spec:
+        return s.inst_str(iso_only=True)
+
+    @pytest.mark.parametrize("date_str,datetime_obj", ISO_DATETIMES)
+    def test_iso_inst_str_validation(
+        self, iso_inst_str_spec: Spec, date_str, datetime_obj
+    ):
+        assert iso_inst_str_spec.is_valid(date_str)
+        assert datetime_obj == iso_inst_str_spec.conform(date_str)
+
+    @pytest.mark.parametrize(
+        "v",
+        ["", "abcde", "Tue September 32 2019", 5, 3.14, None, {}, set(), []]
+        + list(map(lambda v: v[0], NON_ISO_DATETIMES)),
+    )
+    def test_iso_inst_str_validation_failure(self, iso_inst_str_spec: Spec, v):
+        assert not iso_inst_str_spec.is_valid(v)
+        assert INVALID is iso_inst_str_spec.conform(v)
+
+
 def test_nilable():
     assert s.nilable(s.is_str).is_valid(None)
     assert s.nilable(s.is_str).is_valid("")
@@ -1059,7 +1168,7 @@ class TestUUIDSpecValidation:
             return s.uuid(versions={1, 4})
 
         @pytest.mark.parametrize("versions", [{1, 3, 4, 8}, {1, -1}, {"1", "3", "5"}])
-        def test_invalid_uuid_version_spec(self, versions) -> Spec:
+        def test_invalid_uuid_version_spec(self, versions):
             with pytest.raises(ValueError):
                 s.uuid(versions=versions)