Repository: coverahealth/dataspec

Issue:
Support `strptime` formats for `s.date`, `s.inst`, and `s.time`
We should add support for `datetime.strptime` to let users specify arbitrary string format strings for `s.date`, `s.inst`, and `s.time` spec factories.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f91622..a261bc1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add email address string format (#6)
 - Add URL string format factory `s.url` (#16)
 - Add Python `dateutil` support for parsing dates (#5)
-- Add Python `phonenumbers` support for parsing international telephone numbes (#10)
+- Add Python `phonenumbers` support for parsing international telephone numbers (#10)
+- Add format string (`strptime`) support to date/time spec factories (#29)
 
 ### Changed
 - `s.all` and `s.any` create `ValidatorSpec`s now rather than `PredicateSpec`s
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 6ebdaea..3c81657 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -298,7 +298,11 @@ def every_spec(
     return PredicateSpec(tag or "every", always_true, conformer=conformer)
 
 
-def _make_datetime_spec_factory(
+_DEFAULT_STRPTIME_DATE = date(1900, 1, 1)
+_DEFAULT_STRPTIME_TIME = time()
+
+
+def _make_datetime_spec_factory(  # noqa: MC0001
     type_: Union[Type[datetime], Type[date], Type[time]]
 ) -> Callable[..., Spec]:
     """
@@ -317,10 +321,23 @@ def _make_datetime_spec_factory(
     :py:class:`datetime.date` values cannot be aware or naive."""
     )
 
+    format_docstring_blurb = (
+        f"""If the
+    :py:class:`datetime.datetime` object parsed from the ``format_`` string contains
+    a portion not available in :py:class:`datetime.{type_.__name__}, then the
+    validator will emit an error at runtime."""
+        if type_ is not datetime
+        else ""
+    )
+
     docstring = f"""
     Return a Spec which validates :py:class:`datetime.{type_.__name__}` types with
     common rules.
 
+    If ``format_`` is specified, the resulting Spec will accept string values and
+    attempt to coerce them to :py:class:`datetime.{type_.__name__}` instances first
+    before applying the other specified validations. {format_docstring_blurb}
+
     If ``before`` is specified, the resulting Spec will validate that input values
     are before ``before`` by Python's ``<`` operator. If ``after`` is specified, the
     resulting Spec will validate that input values are after ``after`` by Python's
@@ -330,6 +347,10 @@ def _make_datetime_spec_factory(
     {aware_docstring_blurb}
 
     :param tag: an optional tag for the resulting spec
+    :param format: if specified, a time format string which will be fed to
+        :py:meth:`datetime.{type_.__name__}.strptime` to convert the input string
+        to a :py:class:`datetime.{type_.__name__}` before applying the other
+        validations
     :param before: if specified, the input value must come before this date or time
     :param after: if specified, the input value must come after this date or time
     :param is_aware: if :py:obj:`True`, validate that input objects are timezone
@@ -339,8 +360,30 @@ def _make_datetime_spec_factory(
     :return: a Spec which validates :py:class:`datetime.{type_.__name__}` types
     """
 
-    def _datetime_spec_factory(
+    if type_ is date:
+
+        def strptime(s: str, fmt: str) -> date:
+            parsed = datetime.strptime(s, fmt)
+            if parsed.time() != _DEFAULT_STRPTIME_TIME:
+                raise TypeError(f"Parsed time includes date portion: {parsed.time()}")
+            return parsed.date()
+
+    elif type_ is time:
+
+        def strptime(s: str, fmt: str) -> time:  # type: ignore
+            parsed = datetime.strptime(s, fmt)
+            if parsed.date() != _DEFAULT_STRPTIME_DATE:
+                raise TypeError(f"Parsed date includes time portion: {parsed.date()}")
+            return parsed.time()
+
+    else:
+        assert type_ is datetime
+
+        strptime = datetime.strptime  # type: ignore
+
+    def _datetime_spec_factory(  # pylint: disable=too-many-arguments
         tag: Optional[Tag] = None,
+        format_: Optional[str] = None,
         before: Optional[type_] = None,  # type: ignore
         after: Optional[type_] = None,  # type: ignore
         is_aware: Optional[bool] = None,
@@ -399,9 +442,45 @@ def _make_datetime_spec_factory(
             elif is_aware is True:
                 raise TypeError(f"Type {type_} cannot be timezone aware")
 
-        return ValidatorSpec.from_validators(
-            tag or type_.__name__, *validators, conformer=conformer
-        )
+        if format_ is not None:
+
+            def conform_datetime_str(s: str) -> Union[datetime, date, time, Invalid]:
+                try:
+                    return strptime(s, format_)  # type: ignore
+                except (TypeError, ValueError):
+                    return INVALID
+
+            def validate_datetime_str(s: str) -> Iterator[ErrorDetails]:
+                try:
+                    dt = strptime(s, format_)  # type: ignore
+                except TypeError as e:
+                    yield ErrorDetails(
+                        message=f"String contains invalid portion for type: {e}",
+                        pred=validate_datetime_str,
+                        value=s,
+                    )
+                except ValueError:
+                    yield ErrorDetails(
+                        message=(
+                            "String does not contain a date which can be "
+                            f"parsed as '{format_}'"
+                        ),
+                        pred=validate_datetime_str,
+                        value=s,
+                    )
+                else:
+                    for validate in validators:
+                        yield from validate(dt)
+
+            return ValidatorSpec(
+                tag or type.__name__,
+                validate_datetime_str,
+                conformer=conformer or conform_datetime_str,
+            )
+        else:
+            return ValidatorSpec.from_validators(
+                tag or type_.__name__, *validators, conformer=conformer
+            )
 
     _datetime_spec_factory.__doc__ = docstring
     return _datetime_spec_factory


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index de0d9f1..44f7962 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -352,6 +352,38 @@ class TestInstSpecValidation:
     def test_is_inst_failure(self, v):
         assert not s.is_inst.is_valid(v)
 
+    class TestFormatSpec:
+        @pytest.fixture
+        def format_spec(self) -> Spec:
+            return s.inst(format_="%Y-%m-%d %I:%M:%S")
+
+        @pytest.mark.parametrize(
+            "v,parsed",
+            [
+                ("2003-01-14 01:41:16", datetime(2003, 1, 14, 1, 41, 16)),
+                ("0994-12-31 08:00:00", datetime(994, 12, 31, 8)),
+            ],
+        )
+        def test_is_inst_spec(self, format_spec: Spec, v, parsed: date):
+            assert format_spec.is_valid(v)
+            assert parsed == format_spec.conform(v)
+
+        @pytest.mark.parametrize(
+            "v",
+            [
+                "994-12-31",
+                "2000-13-20",
+                "1984-09-32",
+                "84-10-4",
+                "23:18:22",
+                "11:40:72",
+                "06:89:13",
+            ],
+        )
+        def test_is_not_inst_spec(self, format_spec: Spec, v):
+            assert not format_spec.is_valid(v)
+            assert INVALID is format_spec.conform(v)
+
     class TestBeforeSpec:
         @pytest.fixture
         def before_spec(self) -> Spec:
@@ -469,6 +501,34 @@ class TestDateSpecValidation:
     def test_is_date_failure(self, v):
         assert not s.is_date.is_valid(v)
 
+    class TestFormatSpec:
+        @pytest.fixture
+        def format_spec(self) -> Spec:
+            return s.date(format_="%Y-%m-%d")
+
+        @pytest.mark.parametrize(
+            "v,parsed",
+            [("2003-01-14", date(2003, 1, 14)), ("0994-12-31", date(994, 12, 31))],
+        )
+        def test_is_date_spec(self, format_spec: Spec, v, parsed: date):
+            assert format_spec.is_valid(v)
+            assert parsed == format_spec.conform(v)
+
+        # Even though a value like "2003-1-14" should be invalid by the chosen
+        # format string, Python's strptime implementation appears to be more
+        # lenient than it should: https://bugs.python.org/issue33941
+        @pytest.mark.parametrize(
+            "v", ["994-12-31", "2000-13-20", "1984-09-32", "84-10-4"]
+        )
+        def test_is_not_date_spec(self, format_spec: Spec, v):
+            assert not format_spec.is_valid(v)
+            assert INVALID is format_spec.conform(v)
+
+        def test_date_spec_with_time_fails(self):
+            assert not s.date(format_="%Y-%m-%d %H:%M:%S").is_valid(
+                "2003-11-20 22:16:08"
+            )
+
     class TestBeforeSpec:
         @pytest.fixture
         def before_spec(self) -> Spec:
@@ -567,6 +627,28 @@ class TestTimeSpecValidation:
     def test_is_time_failure(self, v):
         assert not s.is_time.is_valid(v)
 
+    class TestFormatSpec:
+        @pytest.fixture
+        def format_spec(self) -> Spec:
+            return s.time(format_="%I:%M:%S")
+
+        @pytest.mark.parametrize(
+            "v,parsed", [("01:41:16", time(1, 41, 16)), ("08:00:00", time(8))]
+        )
+        def test_is_time_spec(self, format_spec: Spec, v, parsed: date):
+            assert format_spec.is_valid(v)
+            assert parsed == format_spec.conform(v)
+
+        @pytest.mark.parametrize("v", ["23:18:22", "11:40:72", "06:89:13"])
+        def test_is_not_time_spec(self, format_spec: Spec, v):
+            assert not format_spec.is_valid(v)
+            assert INVALID is format_spec.conform(v)
+
+        def test_time_spec_with_date_fails(self):
+            assert not s.time(format_="%Y-%m-%d %H:%M:%S").is_valid(
+                "2003-11-20 22:16:08"
+            )
+
     class TestBeforeSpec:
         @pytest.fixture
         def before_spec(self) -> Spec: