Repository: marshmallow-code/marshmallow
Pull request #1998 (2022-06-03 04:30:47)

Support serialization as float in TimeDelta field
Add functionality to (de)serialize to float values as a representation of the total seconds. 

In our use case, we are trying to specify a period which is a combination of seconds, milliseconds and microseconds, which is easier to represent as a single floating point value rather than specifying the smallest unit (microseconds). We have currently been serializing to a `fields.Float()` using `.total_seconds()` and the equivalent to deserialize.

The feature does not promise absolute precision, which is stated in the docs (same restrictions as floating point values).

This does not change the current way `TimeDelta` is used.



Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 7f557b7f..f4aec167 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -169,3 +169,4 @@ Contributors (chronological)
 - Kevin Kirsche `@kkirsche <https://github.com/kkirsche>`_
 - Isira Seneviratne `@Isira-Seneviratne <https://github.com/Isira-Seneviratne>`_
 - Karthikeyan Singaravelan `@tirkarthi  <https://github.com/tirkarthi>`_
+- Marco Satti `@marcosatti  <https://github.com/marcosatti>`_
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 6b26126e..5692d8f0 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1434,17 +1434,35 @@ class Date(DateTime):
 
 class TimeDelta(Field):
     """A field that (de)serializes a :class:`datetime.timedelta` object to an
-    integer and vice versa. The integer can represent the number of days,
-    seconds or microseconds.
+    integer or float and vice versa. The integer or float can represent the
+    number of days, seconds or microseconds.
 
-    :param precision: Influences how the integer is interpreted during
+    :param precision: Influences how the integer or float is interpreted during
         (de)serialization. Must be 'days', 'seconds', 'microseconds',
         'milliseconds', 'minutes', 'hours' or 'weeks'.
+    :param serialization_type: Whether to (de)serialize to a `int` or `float`.
     :param kwargs: The same keyword arguments that :class:`Field` receives.
 
+    Integer Caveats
+    ---------------
+    Any fractional parts (which depends on the precision used) will be truncated
+    when serializing using `int`.
+
+    Float Caveats
+    -------------
+    Use of `float` when (de)serializing may result in data precision loss due
+    to the way machines handle floating point values.
+
+    Regardless of the precision chosen, the fractional part when using `float`
+    will always be truncated to microseconds.
+    For example, `1.12345` interpreted as microseconds will result in `timedelta(microseconds=1)`.
+
     .. versionchanged:: 2.0.0
         Always serializes to an integer value to avoid rounding errors.
         Add `precision` parameter.
+    .. versionchanged:: 3.17.0
+        Allow (de)serialization to `float` through use of a new `serialization_type` parameter.
+        `int` is the default to retain previous behaviour.
     """
 
     DAYS = "days"
@@ -1461,7 +1479,12 @@ class TimeDelta(Field):
         "format": "{input!r} cannot be formatted as a timedelta.",
     }
 
-    def __init__(self, precision: str = SECONDS, **kwargs):
+    def __init__(
+        self,
+        precision: str = SECONDS,
+        serialization_type: type[int | float] = int,
+        **kwargs,
+    ):
         precision = precision.lower()
         units = (
             self.DAYS,
@@ -1479,20 +1502,30 @@ class TimeDelta(Field):
             )
             raise ValueError(msg)
 
+        if serialization_type not in (int, float):
+            raise ValueError("The serialization type must be one of int or float")
+
         self.precision = precision
+        self.serialization_type = serialization_type
         super().__init__(**kwargs)
 
     def _serialize(self, value, attr, obj, **kwargs):
         if value is None:
             return None
+
         base_unit = dt.timedelta(**{self.precision: 1})
-        delta = utils.timedelta_to_microseconds(value)
-        unit = utils.timedelta_to_microseconds(base_unit)
-        return delta // unit
+
+        if self.serialization_type is int:
+            delta = utils.timedelta_to_microseconds(value)
+            unit = utils.timedelta_to_microseconds(base_unit)
+            return delta // unit
+        else:
+            assert self.serialization_type is float
+            return value.total_seconds() / base_unit.total_seconds()
 
     def _deserialize(self, value, attr, data, **kwargs):
         try:
-            value = int(value)
+            value = self.serialization_type(value)
         except (TypeError, ValueError) as error:
             raise self.make_error("invalid") from error
 


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index b5f87c18..4ee5ba5b 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -692,6 +692,67 @@ class TestFieldDeserialization:
         assert result.seconds == 123
         assert result.microseconds == 456000
 
+        total_microseconds_value = 322.0
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        result = field.deserialize(total_microseconds_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(microseconds=1).total_seconds()
+        assert math.isclose(
+            result.total_seconds() / unit_value, total_microseconds_value
+        )
+
+        total_microseconds_value = 322.12345
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        result = field.deserialize(total_microseconds_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(microseconds=1).total_seconds()
+        assert math.isclose(
+            result.total_seconds() / unit_value, math.floor(total_microseconds_value)
+        )
+
+        total_milliseconds_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS, float)
+        result = field.deserialize(total_milliseconds_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(milliseconds=1).total_seconds()
+        assert math.isclose(
+            result.total_seconds() / unit_value, total_milliseconds_value
+        )
+
+        total_seconds_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.SECONDS, float)
+        result = field.deserialize(total_seconds_value)
+        assert isinstance(result, dt.timedelta)
+        assert math.isclose(result.total_seconds(), total_seconds_value)
+
+        total_minutes_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES, float)
+        result = field.deserialize(total_minutes_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(minutes=1).total_seconds()
+        assert math.isclose(result.total_seconds() / unit_value, total_minutes_value)
+
+        total_hours_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.HOURS, float)
+        result = field.deserialize(total_hours_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(hours=1).total_seconds()
+        assert math.isclose(result.total_seconds() / unit_value, total_hours_value)
+
+        total_days_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.DAYS, float)
+        result = field.deserialize(total_days_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(days=1).total_seconds()
+        assert math.isclose(result.total_seconds() / unit_value, total_days_value)
+
+        total_weeks_value = 322.223
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS, float)
+        result = field.deserialize(total_weeks_value)
+        assert isinstance(result, dt.timedelta)
+        unit_value = dt.timedelta(weeks=1).total_seconds()
+        assert math.isclose(result.total_seconds() / unit_value, total_weeks_value)
+
     @pytest.mark.parametrize("in_value", ["", "badvalue", [], 9999999999])
     def test_invalid_timedelta_field_deserialization(self, in_value):
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 56a79c44..41eed709 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -5,6 +5,7 @@ import itertools
 import decimal
 import uuid
 import ipaddress
+import math
 
 import pytest
 
@@ -721,6 +722,58 @@ class TestFieldSerialization:
         field = fields.TimeDelta(fields.TimeDelta.SECONDS)
         assert field.serialize("d9", user) == 1
 
+        user.d10 = dt.timedelta(
+            weeks=1,
+            days=6,
+            hours=2,
+            minutes=5,
+            seconds=51,
+            milliseconds=10,
+            microseconds=742,
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS, float)
+        unit_value = dt.timedelta(microseconds=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS, float)
+        unit_value = dt.timedelta(milliseconds=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.SECONDS, float)
+        assert math.isclose(field.serialize("d10", user), user.d10.total_seconds())
+
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES, float)
+        unit_value = dt.timedelta(minutes=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.HOURS, float)
+        unit_value = dt.timedelta(hours=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.DAYS, float)
+        unit_value = dt.timedelta(days=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS, float)
+        unit_value = dt.timedelta(weeks=1).total_seconds()
+        assert math.isclose(
+            field.serialize("d10", user), user.d10.total_seconds() / unit_value
+        )
+
+        with pytest.raises(ValueError):
+            fields.TimeDelta(fields.TimeDelta.SECONDS, str)
+
     def test_datetime_list_field(self):
         obj = DateTimeList([dt.datetime.utcnow(), dt.datetime.now()])
         field = fields.List(fields.DateTime)