Repository: wtforms/wtforms (python)

Issue:
DateTimeLocalField should support `T` separator
The [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings) we can read that:

>When you set the value of a datetime-local input, the string is normalized into a standard form. Normalized datetime strings always use the letter "T" to separate the date and the time, and the time portion of the string is as short as possible. This is done by leaving out the seconds component if its value is :00.

However, wtforms currently does not support that `T` separator:

```python
>>> import wtforms
>>> from tests.common import DummyPostData
>>> class F(wtforms.Form):
...     dtl = wtforms.DateTimeLocalField()
>>> F(DummyPostData({"dtl": "2020-10-10 10:10:10"})).dtl.data
datetime.datetime(2020, 10, 10, 10, 10, 10)
>>> F(DummyPostData({"dtl": "2020-10-10T10:10:10"})).dtl.data
>>>
```

I suggest we support that `T` separator out of the box. It also seems this is the separator by default in Firefox.

Might be related with #435

Pull request description:
Date related field can handle several formats
`DateTimeField` and other date related field can now handle a list of `format`.
Any input matching the format will be accepted. The first format in the list is used by default to fill HTML `value` param.

Also, by default `DateTimeLocalFields` accepts two input format with the separator being a space and a T letter. This fixes #720

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index cb53635..fb3940a 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,9 @@ Version x.x.x
 -------------
 Unreleased
 
+-   Fixed :class:`~fields.DateTimeField` and other similar fields can
+    handle multiple formats. :issue:`720` :pr:`721`
+
 Version 3.0.0
 -------------
 
diff --git a/src/wtforms/fields/datetime.py b/src/wtforms/fields/datetime.py
index 1fc8df0..35a6cde 100644
--- a/src/wtforms/fields/datetime.py
+++ b/src/wtforms/fields/datetime.py
@@ -15,7 +15,10 @@ __all__ = (
 
 class DateTimeField(Field):
     """
-    A text field which stores a `datetime.datetime` matching a format.
+    A text field which stores a :class:`datetime.datetime` matching one or
+    several formats. If ``format`` is a list, any input value matching any
+    format will be accepted, and the first format in the list will be used
+    to produce HTML values.
     """
 
     widget = widgets.DateTimeInput()
@@ -24,29 +27,33 @@ class DateTimeField(Field):
         self, label=None, validators=None, format="%Y-%m-%d %H:%M:%S", **kwargs
     ):
         super().__init__(label, validators, **kwargs)
-        self.format = format
-        self.strptime_format = clean_datetime_format_for_strptime(format)
+        self.format = format if isinstance(format, list) else [format]
+        self.strptime_format = clean_datetime_format_for_strptime(self.format)
 
     def _value(self):
         if self.raw_data:
             return " ".join(self.raw_data)
-        return self.data and self.data.strftime(self.format) or ""
+        return self.data and self.data.strftime(self.format[0]) or ""
 
     def process_formdata(self, valuelist):
         if not valuelist:
             return
 
         date_str = " ".join(valuelist)
-        try:
-            self.data = datetime.datetime.strptime(date_str, self.strptime_format)
-        except ValueError as exc:
-            self.data = None
-            raise ValueError(self.gettext("Not a valid datetime value.")) from exc
+        for format in self.strptime_format:
+            try:
+                self.data = datetime.datetime.strptime(date_str, format)
+                return
+            except ValueError:
+                self.data = None
+
+        raise ValueError(self.gettext("Not a valid datetime value."))
 
 
 class DateField(DateTimeField):
     """
-    Same as DateTimeField, except stores a `datetime.date`.
+    Same as :class:`~wtforms.fields.DateTimeField`, except stores a
+    :class:`datetime.date`.
     """
 
     widget = widgets.DateInput()
@@ -59,18 +66,20 @@ class DateField(DateTimeField):
             return
 
         date_str = " ".join(valuelist)
-        try:
-            self.data = datetime.datetime.strptime(
-                date_str, self.strptime_format
-            ).date()
-        except ValueError as exc:
-            self.data = None
-            raise ValueError(self.gettext("Not a valid date value.")) from exc
+        for format in self.strptime_format:
+            try:
+                self.data = datetime.datetime.strptime(date_str, format).date()
+                return
+            except ValueError:
+                self.data = None
+
+        raise ValueError(self.gettext("Not a valid date value."))
 
 
 class TimeField(DateTimeField):
     """
-    Same as DateTimeField, except stores a `time`.
+    Same as :class:`~wtforms.fields.DateTimeField`, except stores a
+    :class:`datetime.time`.
     """
 
     widget = widgets.TimeInput()
@@ -83,19 +92,20 @@ class TimeField(DateTimeField):
             return
 
         time_str = " ".join(valuelist)
-        try:
-            self.data = datetime.datetime.strptime(
-                time_str, self.strptime_format
-            ).time()
-        except ValueError as exc:
-            self.data = None
-            raise ValueError(self.gettext("Not a valid time value.")) from exc
+        for format in self.strptime_format:
+            try:
+                self.data = datetime.datetime.strptime(time_str, format).time()
+                return
+            except ValueError:
+                self.data = None
+
+        raise ValueError(self.gettext("Not a valid time value."))
 
 
 class MonthField(DateField):
     """
-    Same as DateField, except represents a month, stores a `datetime.date`
-    with `day = 1`.
+    Same as :class:`~wtforms.fields.DateField`, except represents a month,
+    stores a :class:`datetime.date` with `day = 1`.
     """
 
     widget = widgets.MonthInput()
@@ -106,7 +116,12 @@ class MonthField(DateField):
 
 class DateTimeLocalField(DateTimeField):
     """
-    Represents an ``<input type="datetime-local">``.
+    Same as :class:`~wtforms.fields.DateTimeField`, but represents an
+    ``<input type="datetime-local">``.
     """
 
     widget = widgets.DateTimeLocalInput()
+
+    def __init__(self, *args, **kwargs):
+        kwargs.setdefault("format", ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"])
+        super().__init__(*args, **kwargs)
diff --git a/src/wtforms/utils.py b/src/wtforms/utils.py
index 772adc7..bbae66f 100644
--- a/src/wtforms/utils.py
+++ b/src/wtforms/utils.py
@@ -18,14 +18,19 @@ _DATETIME_STRIP_ZERO_PADDING_FORMATS_RE = re.compile(
 )
 
 
-def clean_datetime_format_for_strptime(format):
+def clean_datetime_format_for_strptime(formats):
     """
     Remove dashes used to disable zero-padding with strftime formats (for
     compatibiltity with strptime).
     """
-    return re.sub(
-        _DATETIME_STRIP_ZERO_PADDING_FORMATS_RE, lambda m: m[0].replace("-", ""), format
-    )
+    return [
+        re.sub(
+            _DATETIME_STRIP_ZERO_PADDING_FORMATS_RE,
+            lambda m: m[0].replace("-", ""),
+            format,
+        )
+        for format in formats
+    ]
 
 
 class UnsetValue:


Test patch:
diff --git a/tests/fields/test_datetime.py b/tests/fields/test_datetime.py
index d4ee21a..aff2534 100644
--- a/tests/fields/test_datetime.py
+++ b/tests/fields/test_datetime.py
@@ -57,3 +57,14 @@ def test_microseconds():
     F = make_form(a=DateTimeField(format="%Y-%m-%d %H:%M:%S.%f"))
     form = F(DummyPostData(a=["2011-05-07 03:23:14.4242"]))
     assert d == form.a.data
+
+
+def test_multiple_formats():
+    d = datetime(2020, 3, 4, 5, 6)
+    F = make_form(a=DateTimeField(format=["%Y-%m-%d %H:%M", "%Y%m%d%H%M"]))
+
+    form = F(DummyPostData(a=["2020-03-04 05:06"]))
+    assert d == form.a.data
+
+    form = F(DummyPostData(a=["202003040506"]))
+    assert d == form.a.data
diff --git a/tests/fields/test_datetimelocal.py b/tests/fields/test_datetimelocal.py
new file mode 100644
index 0000000..9ca43c6
--- /dev/null
+++ b/tests/fields/test_datetimelocal.py
@@ -0,0 +1,72 @@
+from datetime import datetime
+
+from tests.common import DummyPostData
+
+from wtforms.fields import DateTimeLocalField
+from wtforms.form import Form
+
+
+def make_form(name="F", **fields):
+    return type(str(name), (Form,), fields)
+
+
+class F(Form):
+    a = DateTimeLocalField()
+    b = DateTimeLocalField(format="%Y-%m-%d %H:%M")
+    c = DateTimeLocalField(format="%-m/%-d/%Y %-I:%M")
+
+
+def test_basic():
+    d = datetime(2008, 5, 5, 4, 30, 0, 0)
+    # Basic test with both inputs
+    form = F(
+        DummyPostData(
+            a=["2008-05-05", "04:30:00"], b=["2008-05-05 04:30"], c=["5/5/2008 4:30"]
+        )
+    )
+    assert form.a.data == d
+    assert (
+        form.a()
+        == '<input id="a" name="a" type="datetime-local" value="2008-05-05 04:30:00">'
+    )
+    assert form.b.data == d
+    assert (
+        form.b()
+        == '<input id="b" name="b" type="datetime-local" value="2008-05-05 04:30">'
+    )
+    assert form.c.data == d
+    assert (
+        form.c()
+        == '<input id="c" name="c" type="datetime-local" value="5/5/2008 4:30">'
+    )
+    assert form.validate()
+
+    # Test with a missing input
+    form = F(DummyPostData(a=["2008-05-05"]))
+    assert not form.validate()
+    assert form.a.errors[0] == "Not a valid datetime value."
+
+    form = F(a=d, b=d, c=d)
+    assert form.validate()
+    assert form.a._value() == "2008-05-05 04:30:00"
+    assert form.b._value() == "2008-05-05 04:30"
+    assert form.c._value() == "5/5/2008 4:30"
+
+
+def test_microseconds():
+    d = datetime(2011, 5, 7, 3, 23, 14, 424200)
+    F = make_form(a=DateTimeLocalField(format="%Y-%m-%d %H:%M:%S.%f"))
+    form = F(DummyPostData(a=["2011-05-07 03:23:14.4242"]))
+    assert d == form.a.data
+
+
+def test_separators():
+    dt = datetime(2008, 5, 5, 4, 30, 0, 0)
+
+    form = F(DummyPostData(a=["2008-05-05 04:30:00"]))
+    assert form.a.data == dt
+    assert form.validate()
+
+    form = F(DummyPostData(a=["2008-05-05T04:30:00"]))
+    assert form.a.data == dt
+    assert form.validate()