Repository: django-import-export/django-import-export

Issue:
Improve parsing of DateTimeWidget
I think the following code could be improved, by adding a break after `strptime` has returned without an exception:
https://github.com/django-import-export/django-import-export/blob/029396ddeb2c02e651f3f2b6cc666fe0eb8a647a/import_export/widgets.py#L290-L294
The current version always iterates through all patterns (by default [these](https://docs.djangoproject.com/en/5.0/ref/settings/#datetime-input-formats)), even if for example the first one already matched.

So the changed `clean()` would look like this:
```python
def clean(self, value, row=None, **kwargs):
    dt = None
    if not value:
        return None
    if isinstance(value, datetime):
        dt = value
    else:
        for format_ in self.formats:
            try:
                dt = datetime.strptime(value, format_)
                break  # CHANGE
            except (ValueError, TypeError):
                continue
    if dt:
        if settings.USE_TZ and timezone.is_naive(dt):
            dt = timezone.make_aware(dt)
        return dt
    raise ValueError("Enter a valid date/time.")
```

This would the be analog to the processing of the `DateWidget`, which also returns when `strptime` executes successfully:
https://github.com/django-import-export/django-import-export/blob/029396ddeb2c02e651f3f2b6cc666fe0eb8a647a/import_export/widgets.py#L244-L248


Discussion:
Thanks for raising.  It seems like a reasonable change.  I guess another way of doing it would be to move the 'check format' loop into a new method, which returns when a match is made.  I guess then we could also remove a bit of duplication with the check in `DateWidget`.   Feel free to raise a PR if you can.
 in fact there is a fair amount of duplication in `DateWidget` and `DateTimeWidget`, so perhaps they can be refactored.
Comment here if you do want to pick it up, so no-one else duplicates the change.

Resolving patch:
diff --git a/import_export/widgets.py b/import_export/widgets.py
--- a/import_export/widgets.py
+++ b/import_export/widgets.py
@@ -26,6 +26,40 @@ def format_datetime(value, datetime_format):
     return value.strftime(format_)
 
 
+class _ParseDateTimeMixin(object):
+    """Internal Mixin for shared logic with date and datetime conversions."""
+
+    def __init__(
+        self,
+        format=None,
+        input_formats=None,
+        default_format="%Y-%m-%d",
+        coerce_to_string=True,
+    ):
+        super().__init__(coerce_to_string=coerce_to_string)
+        self.formats = (format,) if format else (input_formats or (default_format,))
+
+    def _parse_value(self, value, value_type):
+        """Attempt to parse the value using the provided formats.
+        Raise ValueError if parsing fails."""
+        if not value:
+            return None
+        if isinstance(value, value_type):
+            return value
+
+        for format_ in self.formats:
+            try:
+                parsed_date = datetime.strptime(value, format_)
+                if value_type is date:
+                    return parsed_date.date()
+                if value_type is time:
+                    return parsed_date.time()
+                return parsed_date
+            except (ValueError, TypeError) as e:
+                logger.debug(str(e))
+        raise ValueError("Value could not be parsed using defined formats.")
+
+
 class Widget:
     """
     A Widget handles converting between import and export representations.
@@ -213,40 +247,25 @@ def render(self, value, obj=None):
         return self.TRUE_VALUES[0] if value else self.FALSE_VALUES[0]
 
 
-class DateWidget(Widget):
+class DateWidget(_ParseDateTimeMixin, Widget):
     """
-    Widget for converting date fields.
+    Widget for converting date fields to Python date instances.
 
     Takes optional ``format`` parameter. If none is set, either
     ``settings.DATE_INPUT_FORMATS`` or ``"%Y-%m-%d"`` is used.
     """
 
     def __init__(self, format=None, coerce_to_string=True):
-        super().__init__(coerce_to_string=coerce_to_string)
-        if format is None:
-            if not settings.DATE_INPUT_FORMATS:
-                formats = ("%Y-%m-%d",)
-            else:
-                formats = settings.DATE_INPUT_FORMATS
-        else:
-            formats = (format,)
-        self.formats = formats
+        super().__init__(
+            format, settings.DATE_INPUT_FORMATS, "%Y-%m-%d", coerce_to_string
+        )
 
     def clean(self, value, row=None, **kwargs):
         """
         :returns: A python date instance.
         :raises: ValueError if the value cannot be parsed using defined formats.
         """
-        if not value:
-            return None
-        if isinstance(value, date):
-            return value
-        for format in self.formats:
-            try:
-                return datetime.strptime(value, format).date()
-            except (ValueError, TypeError) as e:
-                logger.debug(str(e))
-        raise ValueError(_("Value could not be parsed using defined date formats."))
+        return self._parse_value(value, date)
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)
@@ -257,46 +276,33 @@ def render(self, value, obj=None):
         return format_datetime(value, self.formats[0])
 
 
-class DateTimeWidget(Widget):
+class DateTimeWidget(_ParseDateTimeMixin, Widget):
     """
-    Widget for converting date fields.
+    Widget for converting datetime fields to Python datetime instances.
 
     Takes optional ``format`` parameter. If none is set, either
     ``settings.DATETIME_INPUT_FORMATS`` or ``"%Y-%m-%d %H:%M:%S"`` is used.
     """
 
     def __init__(self, format=None, coerce_to_string=True):
-        super().__init__(coerce_to_string=coerce_to_string)
-        if format is None:
-            if not settings.DATETIME_INPUT_FORMATS:
-                formats = ("%Y-%m-%d %H:%M:%S",)
-            else:
-                formats = settings.DATETIME_INPUT_FORMATS
-        else:
-            formats = (format,)
-        self.formats = formats
+        super().__init__(
+            format,
+            settings.DATETIME_INPUT_FORMATS,
+            "%Y-%m-%d %H:%M:%S",
+            coerce_to_string,
+        )
 
     def clean(self, value, row=None, **kwargs):
         """
         :returns: A python datetime instance.
         :raises: ValueError if the value cannot be parsed using defined formats.
         """
-        dt = None
-        if not value:
+        dt = self._parse_value(value, datetime)
+        if dt is None:
             return None
-        if isinstance(value, datetime):
-            dt = value
-        else:
-            for format_ in self.formats:
-                try:
-                    dt = datetime.strptime(value, format_)
-                except (ValueError, TypeError) as e:
-                    logger.debug(str(e))
-        if dt:
-            if settings.USE_TZ and timezone.is_naive(dt):
-                dt = timezone.make_aware(dt)
-            return dt
-        raise ValueError(_("Value could not be parsed using defined datetime formats."))
+        if settings.USE_TZ and timezone.is_naive(dt):
+            return timezone.make_aware(dt)
+        return dt
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)
@@ -309,7 +315,7 @@ def render(self, value, obj=None):
         return format_datetime(value, self.formats[0])
 
 
-class TimeWidget(Widget):
+class TimeWidget(_ParseDateTimeMixin, Widget):
     """
     Widget for converting time fields.
 
@@ -318,31 +324,16 @@ class TimeWidget(Widget):
     """
 
     def __init__(self, format=None, coerce_to_string=True):
-        super().__init__(coerce_to_string=coerce_to_string)
-        if format is None:
-            if not settings.TIME_INPUT_FORMATS:
-                formats = ("%H:%M:%S",)
-            else:
-                formats = settings.TIME_INPUT_FORMATS
-        else:
-            formats = (format,)
-        self.formats = formats
+        super().__init__(
+            format, settings.TIME_INPUT_FORMATS, "%H:%M:%S", coerce_to_string
+        )
 
     def clean(self, value, row=None, **kwargs):
         """
         :returns: A python time instance.
         :raises: ValueError if the value cannot be parsed using defined formats.
         """
-        if not value:
-            return None
-        if isinstance(value, time):
-            return value
-        for format in self.formats:
-            try:
-                return datetime.strptime(value, format).time()
-            except (ValueError, TypeError) as e:
-                logger.debug(str(e))
-        raise ValueError(_("Value could not be parsed using defined time formats."))
+        return self._parse_value(value, time)
 
     def render(self, value, obj=None):
         self._obj_deprecation_warning(obj)


Test patch:
diff --git a/tests/core/tests/admin_integration/test_import.py b/tests/core/tests/admin_integration/test_import.py
--- a/tests/core/tests/admin_integration/test_import.py
+++ b/tests/core/tests/admin_integration/test_import.py
@@ -833,7 +833,7 @@ def test_import_action_invalid_date(self):
         # there should be a single invalid row
         self.assertEqual(1, len(result.invalid_rows))
         self.assertEqual(
-            "Value could not be parsed using defined date formats.",
+            "Value could not be parsed using defined formats.",
             result.invalid_rows[0].error.messages[0],
         )
         # no rows should be imported because we rollback on validation errors
diff --git a/tests/core/tests/test_widgets.py b/tests/core/tests/test_widgets.py
--- a/tests/core/tests/test_widgets.py
+++ b/tests/core/tests/test_widgets.py
@@ -145,7 +145,7 @@ def test_clean_returns_date_when_date_passed(self):
     def test_clean_raises_ValueError(self, mock_logger):
         self.widget = widgets.DateWidget("x")
         with self.assertRaisesRegex(
-            ValueError, "Value could not be parsed using defined date formats."
+            ValueError, "Value could not be parsed using defined formats."
         ):
             self.widget.clean("2021-05-01")
         mock_logger.debug.assert_called_with(
@@ -188,7 +188,7 @@ def test_clean(self):
     def test_clean_raises_ValueError(self, mock_logger):
         self.widget = widgets.DateTimeWidget("x")
         with self.assertRaisesRegex(
-            ValueError, "Value could not be parsed using defined datetime formats."
+            ValueError, "Value could not be parsed using defined formats."
         ):
             self.widget.clean("2021-05-01")
         mock_logger.debug.assert_called_with(
@@ -297,7 +297,7 @@ def test_default_format(self):
     def test_clean_raises_ValueError(self, mock_logger):
         self.widget = widgets.TimeWidget("x")
         with self.assertRaisesRegex(
-            ValueError, "Value could not be parsed using defined time formats."
+            ValueError, "Value could not be parsed using defined formats."
         ):
             self.widget.clean("20:15:00")
         mock_logger.debug.assert_called_with(