Repository: marshmallow-code/marshmallow

Issue:
Add format option for fields.time
ISO 8601 supports following formats:
> hh:mm:ss.sss or hhmmss.sss
> hh:mm:ss or hhmmss
> hh:mm or hhmm
> hh

It would be nice to have a format option for fields.time, like 
`begin = fields.Time(format='hh:mm')`


Discussion:
infinityxxx: @sloria I'm participating in hacktoberfest and would really appreciate if this PR could qualify for that (approved before October ends), I'll do my best to make the required changes if needed! I can also take on more issues :D

Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 40899ce3..417b62a5 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -155,3 +155,4 @@ Contributors (chronological)
 - `@ebargtuo <https://github.com/ebargtuo>`_
 - Michał Getka `@mgetka <https://github.com/mgetka>`_
 - Nadège Michel `@nadege <https://github.com/nadege>`_
+- Tamara `@infinityxxx <https://github.com/infinityxxx>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 36072ec3..e38af023 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,7 +4,12 @@ Changelog
 3.9.0 (unreleased)
 ******************
 
-Changes:
+Features:
+
+- Add ``format`` argument to ``fields.Time`` and ``timeformat`` *class Meta* option (:issue:`686`).
+  Thanks :user:`BennyAlex` for the suggestion and thanks :user:`infinityxxx` for the PR.
+
+Other changes:
 
 - Remove usage of implicit ``typing.Optional`` (:issue:`1663`).
   Thanks :user:`nadega` for the PR.
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 8f0ac273..18f85733 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -1314,34 +1314,29 @@ class AwareDateTime(DateTime):
         return ret
 
 
-class Time(Field):
-    """ISO8601-formatted time string.
+class Time(DateTime):
+    """A formatted time string.
 
+    Example: ``'03:12:58.019077'``
+
+    :param format: Either ``"iso"`` (for ISO8601) or a date format string.
+        If `None`, defaults to "iso".
     :param kwargs: The same keyword arguments that :class:`Field` receives.
     """
 
-    #: Default error messages.
-    default_error_messages = {
-        "invalid": "Not a valid time.",
-        "format": '"{input}" cannot be formatted as a time.',
-    }
+    SERIALIZATION_FUNCS = {"iso": utils.to_iso_time, "iso8601": utils.to_iso_time}
 
-    def _serialize(self, value, attr, obj, **kwargs):
-        if value is None:
-            return None
-        ret = value.isoformat()
-        if value.microsecond:
-            return ret[:15]
-        return ret
+    DESERIALIZATION_FUNCS = {"iso": utils.from_iso_time, "iso8601": utils.from_iso_time}
 
-    def _deserialize(self, value, attr, data, **kwargs):
-        """Deserialize an ISO8601-formatted time to a :class:`datetime.time` object."""
-        if not value:  # falsy values are invalid
-            raise self.make_error("invalid")
-        try:
-            return utils.from_iso_time(value)
-        except (AttributeError, TypeError, ValueError) as error:
-            raise self.make_error("invalid") from error
+    DEFAULT_FORMAT = "iso"
+
+    OBJ_TYPE = "time"
+
+    SCHEMA_OPTS_VAR_NAME = "timeformat"
+
+    @staticmethod
+    def _make_object_from_format(value, data_format):
+        return dt.datetime.strptime(value, data_format).time()
 
 
 class Date(DateTime):
diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py
index 4716ea10..4917eaa6 100644
--- a/src/marshmallow/schema.py
+++ b/src/marshmallow/schema.py
@@ -213,6 +213,7 @@ class SchemaOpts:
             raise ValueError("`exclude` must be a list or tuple.")
         self.dateformat = getattr(meta, "dateformat", None)
         self.datetimeformat = getattr(meta, "datetimeformat", None)
+        self.timeformat = getattr(meta, "timeformat", None)
         if hasattr(meta, "json_module"):
             warnings.warn(
                 "The json_module class Meta option is deprecated. Use render_module instead.",
@@ -345,6 +346,7 @@ class Schema(base.SchemaABC, metaclass=SchemaMeta):
             Nested fields can be represented with dot delimiters.
         - ``dateformat``: Default format for `Date <fields.Date>` fields.
         - ``datetimeformat``: Default format for `DateTime <fields.DateTime>` fields.
+        - ``timeformat``: Default format for `Time <fields.Time>` fields.
         - ``render_module``: Module to use for `loads <Schema.loads>` and `dumps <Schema.dumps>`.
             Defaults to `json` from the standard library.
         - ``ordered``: If `True`, order serialization output according to the
diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py
index 01508ae0..62768ac2 100644
--- a/src/marshmallow/utils.py
+++ b/src/marshmallow/utils.py
@@ -191,6 +191,10 @@ def isoformat(datetime: dt.datetime) -> str:
     return datetime.isoformat()
 
 
+def to_iso_time(time: dt.time) -> str:
+    return dt.time.isoformat(time)
+
+
 def to_iso_date(date: dt.date) -> str:
     return dt.date.isoformat(date)
 


Test patch:
diff --git a/tests/base.py b/tests/base.py
index cd942f58..9ff27df5 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -61,6 +61,7 @@ class User:
         registered=True,
         time_registered=None,
         birthdate=None,
+        birthtime=None,
         balance=100,
         sex="male",
         employer=None,
@@ -85,6 +86,7 @@ class User:
         self.uid = uuid.uuid1()
         self.time_registered = time_registered or dt.time(1, 23, 45, 6789)
         self.birthdate = birthdate or dt.date(2013, 1, 23)
+        self.birthtime = birthtime or dt.time(0, 1, 2, 3333)
         self.activation_date = dt.date(2013, 12, 11)
         self.sex = sex
         self.employer = employer
@@ -169,6 +171,7 @@ class UserSchema(Schema):
     uid = fields.UUID()
     time_registered = fields.Time()
     birthdate = fields.Date()
+    birthtime = fields.Time()
     activation_date = fields.Date()
     since_created = fields.TimeDelta()
     sex = fields.Str(validate=validate.OneOf(["male", "female"]))
@@ -239,6 +242,7 @@ class UserMetaSchema(Schema):
             "uid",
             "time_registered",
             "birthdate",
+            "birthtime",
             "since_created",
             "various_data",
         )
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 6ce39300..1c2dca25 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -589,6 +589,35 @@ class TestFieldDeserialization:
             field.deserialize(in_data)
         assert excinfo.value.args[0] == "Not a valid time."
 
+    def test_custom_time_format_time_field_deserialization(self):
+        # Time string with format "%f.%S:%M:%H"
+        timestring = "123456.12:11:10"
+
+        # Deserialization should fail when timestring is not of same format
+        field = fields.Time(format="%S:%M:%H")
+        with pytest.raises(ValidationError, match="Not a valid time."):
+            field.deserialize(timestring)
+
+        field = fields.Time(format="%f.%S:%M:%H")
+        assert field.deserialize(timestring) == dt.time(10, 11, 12, 123456)
+
+    @pytest.mark.parametrize("fmt", ["iso", "iso8601", None])
+    @pytest.mark.parametrize(
+        ("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)),
+        ],
+    )
+    def test_iso_time_field_deserialization(self, fmt, value, expected):
+        if fmt is None:
+            field = fields.Time()
+        else:
+            field = fields.Time(format=fmt)
+        assert field.deserialize(value) == expected
+
     def test_invalid_timedelta_precision(self):
         with pytest.raises(ValueError, match='The precision must be "days",'):
             fields.TimeDelta("invalid")
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 85646754..2a720374 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1711,6 +1711,22 @@ def test_dateformat_option(user):
     assert serialized["activation_date"] == user.activation_date.strftime(fmt)
 
 
+def test_timeformat_option(user):
+    fmt = "%H:%M:%S"
+    field_fmt = "%H:%M"
+
+    class TimeFormatSchema(Schema):
+        birthtime = fields.Time(field_fmt)
+
+        class Meta:
+            fields = ("birthtime", "time_registered")
+            timeformat = fmt
+
+    serialized = TimeFormatSchema().dump(user)
+    assert serialized["birthtime"] == user.birthtime.strftime(field_fmt)
+    assert serialized["time_registered"] == user.time_registered.strftime(fmt)
+
+
 def test_default_dateformat(user):
     class DateFormatSchema(Schema):
         updated = fields.DateTime(format="%m-%d")
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 9b648054..e4859c8d 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -553,6 +553,28 @@ class TestFieldSerialization:
         user.time_registered = None
         assert field.serialize("time_registered", user) is None
 
+    @pytest.mark.parametrize("fmt", ["iso", "iso8601", None])
+    @pytest.mark.parametrize(
+        ("value", "expected"),
+        [
+            (dt.time(1, 23, 45), "01:23:45"),
+            (dt.time(1, 23, 45, 123000), "01:23:45.123000"),
+            (dt.time(1, 23, 45, 123456), "01:23:45.123456"),
+        ],
+    )
+    def test_time_field_iso8601(self, fmt, value, expected):
+        if fmt is None:
+            # Test default is ISO
+            field = fields.Time()
+        else:
+            field = fields.Time(format=fmt)
+        assert field.serialize("d", {"d": value}) == expected
+
+    def test_time_field_format(self, user):
+        fmt = "%H:%M:%S"
+        field = fields.Time(format=fmt)
+        assert field.serialize("birthtime", user) == user.birthtime.strftime(fmt)
+
     def test_date_field(self, user):
         field = fields.Date()
         assert field.serialize("birthdate", user) == user.birthdate.isoformat()