Repository: marshmallow-code/marshmallow

Issue:
fields.TimeDelta precision
Hi all,

I'm working on a project that uses marshmallow and we're using a subclass of TimeDelta with `hour` precision.
Would you be interested in a PR with support for other precisions?


Discussion:

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 002b8bea..594a6d4a 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -90,3 +90,4 @@ Contributors (chronological)
 - Yoichi NAKAYAMA `@yoichi <https://github.com/yoichi>`_
 - Bernhard M. Wiedemann `@bmwiedemann <https://github.com/bmwiedemann>`_
 - Scott Werner `@scottwernervt <https://github.com/scottwernervt>`_
+- Leonardo Fedalto `@Fedalto <https://github.com/Fedalto>`_
diff --git a/marshmallow/fields.py b/marshmallow/fields.py
index 1c3a5e5a..8a70091e 100755
--- a/marshmallow/fields.py
+++ b/marshmallow/fields.py
@@ -9,7 +9,6 @@ import numbers
 import uuid
 import warnings
 import decimal
-from operator import attrgetter
 
 from marshmallow import validate, utils, class_registry
 from marshmallow.base import FieldABC, SchemaABC
@@ -119,7 +118,7 @@ class Field(FieldABC):
     #: :exc:`marshmallow.ValidationError`.
     default_error_messages = {
         'required': 'Missing data for required field.',
-        'type': 'Invalid input type.', # used by Unmarshaller
+        'type': 'Invalid input type.',  # used by Unmarshaller
         'null': 'Field may not be null.',
         'validator_failed': 'Invalid value.'
     }
@@ -667,7 +666,7 @@ class Number(Field):
         """Format the value or raise a :exc:`ValidationError` if an error occurs."""
         try:
             return self._format_num(value)
-        except (TypeError, ValueError) as err:
+        except (TypeError, ValueError):
             self.fail('invalid')
 
     def serialize(self, attr, obj, accessor=None):
@@ -882,7 +881,7 @@ class FormattedString(Field):
         try:
             data = utils.to_marshallable_type(obj)
             return self.src_str.format(**data)
-        except (TypeError, IndexError) as error:
+        except (TypeError, IndexError):
             self.fail('format')
 
 
@@ -954,7 +953,7 @@ class DateTime(Field):
         if format_func:
             try:
                 return format_func(value, localtime=self.localtime)
-            except (AttributeError, ValueError) as err:
+            except (AttributeError, ValueError):
                 self.fail('format', input=value)
         else:
             return value.strftime(self.dateformat)
@@ -1020,7 +1019,6 @@ class Time(Field):
         """Deserialize an ISO8601-formatted time to a :class:`datetime.time` object."""
         if not value:   # falsy values are invalid
             self.fail('invalid')
-            raise err
         try:
             return utils.from_iso_time(value)
         except (AttributeError, TypeError, ValueError):
@@ -1063,7 +1061,8 @@ class TimeDelta(Field):
     seconds or microseconds.
 
     :param str precision: Influences how the integer is interpreted during
-        (de)serialization. Must be 'days', 'seconds' or 'microseconds'.
+        (de)serialization. Must be 'days', 'seconds', 'microseconds',
+        'milliseconds', 'minutes', 'hours' or 'weeks'.
     :param str error: Error message stored upon validation failure.
     :param kwargs: The same keyword arguments that :class:`Field` receives.
 
@@ -1075,6 +1074,10 @@ class TimeDelta(Field):
     DAYS = 'days'
     SECONDS = 'seconds'
     MICROSECONDS = 'microseconds'
+    MILLISECONDS = 'milliseconds'
+    MINUTES = 'minutes'
+    HOURS = 'hours'
+    WEEKS = 'weeks'
 
     default_error_messages = {
         'invalid': 'Not a valid period of time.',
@@ -1083,10 +1086,12 @@ class TimeDelta(Field):
 
     def __init__(self, precision='seconds', error=None, **kwargs):
         precision = precision.lower()
-        units = (self.DAYS, self.SECONDS, self.MICROSECONDS)
+        units = (self.DAYS, self.SECONDS, self.MICROSECONDS, self.MILLISECONDS,
+                 self.MINUTES, self.HOURS, self.WEEKS)
 
         if precision not in units:
-            msg = 'The precision must be "{0}", "{1}" or "{2}".'.format(*units)
+            msg = 'The precision must be "{0}" or "{1}".'.format(
+                ', '.join(units[:-1]), units[-1])
             raise ValueError(msg)
 
         self.precision = precision
@@ -1096,15 +1101,8 @@ class TimeDelta(Field):
         if value is None:
             return None
         try:
-            days = value.days
-            if self.precision == self.DAYS:
-                return days
-            else:
-                seconds = days * 86400 + value.seconds
-                if self.precision == self.SECONDS:
-                    return seconds
-                else:  # microseconds
-                    return seconds * 10**6 + value.microseconds  # flake8: noqa
+            base_unit = dt.timedelta(**{self.precision: 1})
+            return int(value.total_seconds() / base_unit.total_seconds())
         except AttributeError:
             self.fail('format', input=value)
 
@@ -1196,6 +1194,7 @@ class Email(ValidatedField, String):
     :param kwargs: The same keyword arguments that :class:`String` receives.
     """
     default_error_messages = {'invalid': 'Not a valid email address.'}
+
     def __init__(self, *args, **kwargs):
         String.__init__(self, *args, **kwargs)
         # Insert validation into self.validators so that multiple errors can be
@@ -1305,7 +1304,6 @@ class Function(Field):
             return func(value)
 
 
-
 class Constant(Field):
     """A field that (de)serializes to a preset constant.  If you only want the
     constant added for serialization or deserialization, you should use


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 9549ec55..43eb0b89 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -468,6 +468,34 @@ class TestFieldDeserialization:
         assert result.seconds == 12
         assert result.microseconds == 0
 
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS)
+        result = field.deserialize(1)
+        assert isinstance(result, dt.timedelta)
+        assert result.days == 7
+        assert result.seconds == 0
+        assert result.microseconds == 0
+
+        field = fields.TimeDelta(fields.TimeDelta.HOURS)
+        result = field.deserialize(25)
+        assert isinstance(result, dt.timedelta)
+        assert result.days == 1
+        assert result.seconds == 3600
+        assert result.microseconds == 0
+
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES)
+        result = field.deserialize(1441)
+        assert isinstance(result, dt.timedelta)
+        assert result.days == 1
+        assert result.seconds == 60
+        assert result.microseconds == 0
+
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
+        result = field.deserialize(123456)
+        assert isinstance(result, dt.timedelta)
+        assert result.days == 0
+        assert result.seconds == 123
+        assert result.microseconds == 456000
+
     @pytest.mark.parametrize('in_value',
     [
         '',
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index b7ebb9a4..3f3c290d 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -594,6 +594,8 @@ class TestFieldSerialization:
         user.d3 = dt.timedelta(days=0, seconds=0, microseconds=86401000001)
         user.d4 = dt.timedelta(days=0, seconds=0, microseconds=0)
         user.d5 = dt.timedelta(days=-1, seconds=0, microseconds=0)
+        user.d6 = dt.timedelta(days=1, seconds=1, microseconds=1,
+                               milliseconds=1, minutes=1, hours=1, weeks=1)
 
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
         assert field.serialize('d1', user) == 1
@@ -601,6 +603,8 @@ class TestFieldSerialization:
         assert field.serialize('d1', user) == 86401
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         assert field.serialize('d1', user) == 86401000001
+        field = fields.TimeDelta(fields.TimeDelta.HOURS)
+        assert field.serialize('d1', user) == 24
 
         field = fields.TimeDelta(fields.TimeDelta.DAYS)
         assert field.serialize('d2', user) == 1
@@ -630,8 +634,28 @@ class TestFieldSerialization:
         field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
         assert field.serialize('d5', user) == -86400000000
 
-        user.d6 = None
-        assert field.serialize('d6', user) is None
+        field = fields.TimeDelta(fields.TimeDelta.WEEKS)
+        assert field.serialize('d6', user) == 1
+        field = fields.TimeDelta(fields.TimeDelta.DAYS)
+        assert field.serialize('d6', user) == 7 + 1
+        field = fields.TimeDelta(fields.TimeDelta.HOURS)
+        assert field.serialize('d6', user) == 7 * 24 + 24 + 1
+        field = fields.TimeDelta(fields.TimeDelta.MINUTES)
+        assert field.serialize('d6', user) == 7 * 24 * 60 + 24 * 60 + 60 + 1
+        d6_seconds = (7 * 24 * 60 * 60 +  # 1 week
+                      24 * 60 * 60 +  # 1 day
+                      60 * 60 +  # 1 hour
+                      60 +  # 1 minute
+                      1)
+        field = fields.TimeDelta(fields.TimeDelta.SECONDS)
+        assert field.serialize('d6', user) == d6_seconds
+        field = fields.TimeDelta(fields.TimeDelta.MILLISECONDS)
+        assert field.serialize('d6', user) == d6_seconds * 1000 + 1
+        field = fields.TimeDelta(fields.TimeDelta.MICROSECONDS)
+        assert field.serialize('d6', user) == d6_seconds * 10**6 + 1000 + 1
+
+        user.d7 = None
+        assert field.serialize('d7', user) is None
 
     def test_datetime_list_field(self):
         obj = DateTimeList([dt.datetime.utcnow(), dt.datetime.now()])