Repository: marshmallow-code/marshmallow
Pull request #2078 (2022-12-08 16:14:02)

Use fromisoformat from standard library (or backport)
This PR shows what it would look like if we used Python 3.11's improved `fromisoformat` (backported for older releases).

Less code on our side and it should be more efficient (C implementation).

(We could simplify even more by passing those functions directly to the class and remove everything from utils.py.)

There are 2 failing tests.

- "2018-01-01" is accepted as a datetime while it is currently rejected in marshmallow. I guess this would be a breaking change. We may keep the old behaviour with a temporary trick until marshmallow 4 (keep the regex or even just check input string length).

- Time deserialization respects timezones. It is not clear to me what a time with timezone but without date means. It could be ambiguous for timezones with DST. In any case, this can be considered either bugfix or breaking change, and it could be "fixed" to keep old behaviour.

-------------------------------------

Consider this as an RFC.

Meanwhile, anyone willing to do it in their app may set those functions as class attributes and use the backport if needed.

lafrech: We may want to keep things as is in marshmallow 3 but I think we should be using standard lib `fromisoformat` in marshmallow 4 and this is at least a preview of the minor changes that involves.
ddelange: nice cleanup! 4.0?
sloria: Lgtm for 4.0. I think we can also get rid of the `utils.py` functions, so long as there's a clear migration path documented in the changelog
lafrech: Changelog suggestion:


- *Backwards-incompatible*: Use ``fromisoformat`` from standard library to
  deserialize dates, times and datetimes.
  Time with time offset is now supported.
  `YYYY-MM-DD` is now accepted as a datetime and deserialized as naive 00:00 AM.
  Remove ``from_iso_date``, ``from_iso_time`` and ``from_iso_datetime`` from utils.py.
  (:pr:`2078`)
lafrech:  I just moved a few issues from 4.0 milestone to 5.0.

We may postpone more stuff if there's still too much left.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ce9a715f..a861c3d0 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,7 +4,14 @@ Changelog
 4.0.0 (unreleased)
 ******************
 
-- `marshmallow.fields.TimeDelta` no longer truncates float values when
+- *Backwards-incompatible*: Use `datetime.date.fromisoformat`, `datetime.time.fromisoformat`, and `datetime.datetime.fromisoformat` from the standard library to deserialize dates, times and datetimes (:pr:`2078`). 
+
+As a consequence of this change:
+  - Time with time offsets are now supported.
+  - YYYY-MM-DD is now accepted as a datetime and deserialized as naive 00:00 AM.
+  - `from_iso_date`, `from_iso_time` and `from_iso_datetime` are removed from `marshmallow.utils`
+
+- *Backwards-incompatible*: `marshmallow.fields.TimeDelta` no longer truncates float values when
   deserializing (:pr:`2654`). This allows microseconds to be preserved, e.g.
 
 .. code-block:: python
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 13701a34..8b55c940 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,4 +1,4 @@
 .. seealso::
-  Need help upgrading to marshmallow 3? Check out the :doc:`upgrading guide <upgrading>`.
+  Need help upgrading to newer versions? Check out the :doc:`upgrading guide <upgrading>`.
 
 .. include:: ../CHANGELOG.rst
diff --git a/docs/conf.py b/docs/conf.py
index 8075b137..0d903366 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -15,7 +15,7 @@ extensions = [
 primary_domain = "py"
 default_role = "py:obj"
 
-intersphinx_mapping = {"python": ("https://python.readthedocs.io/en/latest/", None)}
+intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
 
 issues_github_path = "marshmallow-code/marshmallow"
 
diff --git a/pyproject.toml b/pyproject.toml
index 1fea8dce..b603cf5b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,10 @@ classifiers = [
   "Programming Language :: Python :: 3.13",
 ]
 requires-python = ">=3.9"
-dependencies = ["packaging>=17.0"]
+dependencies = [
+    "packaging>=17.0",
+    "backports-datetime-fromisoformat; python_version < '3.11'",
+]
 
 [project.urls]
 Changelog = "https://marshmallow.readthedocs.io/en/latest/changelog.html"
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 1225555c..6531b9ef 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -15,6 +15,14 @@ import warnings
 from collections.abc import Mapping as _Mapping
 from enum import Enum as EnumType
 
+# Remove this when dropping Python 3.10
+try:
+    from backports.datetime_fromisoformat import MonkeyPatch
+except ImportError:
+    pass
+else:
+    MonkeyPatch.patch_fromisoformat()
+
 from marshmallow import class_registry, types, utils, validate
 from marshmallow.base import FieldABC, SchemaABC
 from marshmallow.exceptions import (
@@ -1248,8 +1256,8 @@ class DateTime(Field):
     }  # type: dict[str, typing.Callable[[typing.Any], str | float]]
 
     DESERIALIZATION_FUNCS = {
-        "iso": utils.from_iso_datetime,
-        "iso8601": utils.from_iso_datetime,
+        "iso": dt.datetime.fromisoformat,
+        "iso8601": dt.datetime.fromisoformat,
         "rfc": utils.from_rfc,
         "rfc822": utils.from_rfc,
         "timestamp": utils.from_timestamp,
@@ -1397,7 +1405,10 @@ class Time(DateTime):
 
     SERIALIZATION_FUNCS = {"iso": utils.to_iso_time, "iso8601": utils.to_iso_time}
 
-    DESERIALIZATION_FUNCS = {"iso": utils.from_iso_time, "iso8601": utils.from_iso_time}
+    DESERIALIZATION_FUNCS = {
+        "iso": dt.time.fromisoformat,
+        "iso8601": dt.time.fromisoformat,
+    }
 
     DEFAULT_FORMAT = "iso"
 
@@ -1426,7 +1437,10 @@ class Date(DateTime):
 
     SERIALIZATION_FUNCS = {"iso": utils.to_iso_date, "iso8601": utils.to_iso_date}
 
-    DESERIALIZATION_FUNCS = {"iso": utils.from_iso_date, "iso8601": utils.from_iso_date}
+    DESERIALIZATION_FUNCS = {
+        "iso": dt.date.fromisoformat,
+        "iso8601": dt.date.fromisoformat,
+    }
 
     DEFAULT_FORMAT = "iso"
 
diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py
index f7247319..328e0726 100644
--- a/src/marshmallow/utils.py
+++ b/src/marshmallow/utils.py
@@ -7,7 +7,6 @@ import datetime as dt
 import functools
 import inspect
 import json
-import re
 import typing
 import warnings
 from collections.abc import Mapping
@@ -116,23 +115,6 @@ def rfcformat(datetime: dt.datetime) -> str:
     return format_datetime(datetime)
 
 
-# Hat tip to Django for ISO8601 deserialization functions
-
-_iso8601_datetime_re = re.compile(
-    r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
-    r"[T ](?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
-    r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
-    r"(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$"
-)
-
-_iso8601_date_re = re.compile(r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})$")
-
-_iso8601_time_re = re.compile(
-    r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
-    r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
-)
-
-
 def get_fixed_timezone(offset: int | float | dt.timedelta) -> dt.timezone:
     """Return a tzinfo instance with a fixed offset from UTC."""
     if isinstance(offset, dt.timedelta):
@@ -143,54 +125,6 @@ def get_fixed_timezone(offset: int | float | dt.timedelta) -> dt.timezone:
     return dt.timezone(dt.timedelta(minutes=offset), name)
 
 
-def from_iso_datetime(value):
-    """Parse a string and return a datetime.datetime.
-
-    This function supports time zone offsets. When the input contains one,
-    the output uses a timezone with a fixed offset from UTC.
-    """
-    match = _iso8601_datetime_re.match(value)
-    if not match:
-        raise ValueError("Not a valid ISO8601-formatted datetime string")
-    kw = match.groupdict()
-    kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
-    tzinfo = kw.pop("tzinfo")
-    if tzinfo == "Z":
-        tzinfo = dt.timezone.utc
-    elif tzinfo is not None:
-        offset_mins = int(tzinfo[-2:]) if len(tzinfo) > 3 else 0
-        offset = 60 * int(tzinfo[1:3]) + offset_mins
-        if tzinfo[0] == "-":
-            offset = -offset
-        tzinfo = get_fixed_timezone(offset)
-    kw = {k: int(v) for k, v in kw.items() if v is not None}
-    kw["tzinfo"] = tzinfo
-    return dt.datetime(**kw)
-
-
-def from_iso_time(value):
-    """Parse a string and return a datetime.time.
-
-    This function doesn't support time zone offsets.
-    """
-    match = _iso8601_time_re.match(value)
-    if not match:
-        raise ValueError("Not a valid ISO8601-formatted time string")
-    kw = match.groupdict()
-    kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
-    kw = {k: int(v) for k, v in kw.items() if v is not None}
-    return dt.time(**kw)
-
-
-def from_iso_date(value):
-    """Parse a string and return a datetime.date."""
-    match = _iso8601_date_re.match(value)
-    if not match:
-        raise ValueError("Not a valid ISO8601-formatted date string")
-    kw = {k: int(v) for k, v in match.groupdict().items()}
-    return dt.date(**kw)
-
-
 def from_timestamp(value: typing.Any) -> dt.datetime:
     if value is True or value is False:
         raise ValueError("Not a valid POSIX timestamp")


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index ff20bbf6..5c1e6d0d 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -428,7 +428,7 @@ class TestFieldDeserialization:
             "",
             [],
             "2018",
-            "2018-01-01",
+            "2018-01",
             dt.datetime.now().strftime("%H:%M:%S %Y-%m-%d"),
             dt.datetime.now().strftime("%m-%d-%Y %H:%M:%S"),
         ],
@@ -685,9 +685,12 @@ class TestFieldDeserialization:
         ("value", "expected"),
         [
             ("01:23:45", dt.time(1, 23, 45)),
-            ("01:23:45+01:00", dt.time(1, 23, 45)),
             ("01:23:45.123", dt.time(1, 23, 45, 123000)),
             ("01:23:45.123456", dt.time(1, 23, 45, 123456)),
+            (
+                "01:23:45+01:00",
+                dt.time(1, 23, 45, tzinfo=dt.timezone(dt.timedelta(seconds=3600))),
+            ),
         ],
     )
     def test_iso_time_field_deserialization(self, fmt, value, expected):
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 44baa71f..9c498152 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -6,7 +6,7 @@ from functools import partial
 import pytest
 
 from marshmallow import Schema, fields, utils
-from tests.base import assert_date_equal, assert_time_equal, central
+from tests.base import central
 
 
 def test_missing_singleton_copy():
@@ -175,59 +175,6 @@ def test_from_rfc(value, expected):
     assert result == expected
 
 
-@pytest.mark.parametrize(
-    ("value", "expected"),
-    [
-        ("2013-11-10T01:23:45", dt.datetime(2013, 11, 10, 1, 23, 45)),
-        (
-            "2013-11-10T01:23:45+00:00",
-            dt.datetime(2013, 11, 10, 1, 23, 45, tzinfo=dt.timezone.utc),
-        ),
-        (
-            # Regression test for https://github.com/marshmallow-code/marshmallow/issues/1251
-            "2013-11-10T01:23:45.123+00:00",
-            dt.datetime(2013, 11, 10, 1, 23, 45, 123000, tzinfo=dt.timezone.utc),
-        ),
-        (
-            "2013-11-10T01:23:45.123456+00:00",
-            dt.datetime(2013, 11, 10, 1, 23, 45, 123456, tzinfo=dt.timezone.utc),
-        ),
-        (
-            "2013-11-10T01:23:45-06:00",
-            dt.datetime(2013, 11, 10, 1, 23, 45, tzinfo=central),
-        ),
-    ],
-)
-def test_from_iso_datetime(value, expected):
-    result = utils.from_iso_datetime(value)
-    assert type(result) is dt.datetime
-    assert result == expected
-
-
-def test_from_iso_time_with_microseconds():
-    t = dt.time(1, 23, 45, 6789)
-    formatted = t.isoformat()
-    result = utils.from_iso_time(formatted)
-    assert type(result) is dt.time
-    assert_time_equal(result, t)
-
-
-def test_from_iso_time_without_microseconds():
-    t = dt.time(1, 23, 45)
-    formatted = t.isoformat()
-    result = utils.from_iso_time(formatted)
-    assert type(result) is dt.time
-    assert_time_equal(result, t)
-
-
-def test_from_iso_date():
-    d = dt.date(2014, 8, 21)
-    iso_date = d.isoformat()
-    result = utils.from_iso_date(iso_date)
-    assert type(result) is dt.date
-    assert_date_equal(result, d)
-
-
 @pytest.mark.parametrize(
     ("value", "expected"),
     [