Repository: jcrist/msgspec (python)

Issue:
Expand `datetime` support
Currently msgspec supports encoding and decoding only timezone-aware `datetime.datetime` objects, holding strict conformance to RFC3339. Naive `datetime.datetime` objects can be encoded using a custom `enc_hook`, but there's no way to decode a naive `datetime.datetime` object.

I would like to expand our builtin support for `datetime` types to include:

- `datetime.datetime` (both aware and naive)
- `datetime.date`
- `datetime.time` (both aware and naive)

Here's the plan I've come up with:

## Encoding

To support encoding, we add a `support_naive_datetimes` keyword argument to `msgspec.*.decode` and `msgspec.*.Decoder` to configure the treatment of naive datetimes. This would take one of:

- `False`: the default. Naive `datetime` and `time` objects error on encoding.
- `True`: allow encoding naive `datetime` and `time` objects. These will be encoded as their RFC3339 compatible counterparts, just missing the `offset` component
- `"UTC"`: naive `datetime` and `time` objects will be treated as if they have a UTC timezone.
- a `tzinfo` object: naive `datetime` and `time` objects will be treated as if they have this timezone.

I'm not attached to the keyword name (or boolean options), so if someone can think of a nicer spelling I'd be happy. I think this supports all the common options.

One benefit of supporting these options builtin is that we no longer have the weird behavior of `enc_hook` only being called for naive `datetime.datetime` objects. This would admittedly be less weird if Python had different types for aware and naive datetimes.

I could hear an argument that the default should be `True` (encoding naive datetimes/times by default), but I'm hesitant to make that change. Having an error by default if you're using a naive datetime will force users to think about timezones early on - if they really want a naive datetime they can explicitly opt into it. Supporting naive datetimes/times by default could let programming errors slip by, since most times the user does want an aware datetime rather than a naive datetime.

## Decoding

To support decoding, we want to handle the following use cases:

- Only decode RFC3339 compatible datetimes and times (requiring a timezone)
- Only decode naive datetimes and times (require no timezone)
- Decode any datetime or time object (naive or aware)

Since `msgspec` will only ever decode an object into a datetime if type information is provided, then the natural place to enable this configuration is through our existing type annotations system. The question then is - what does an unannotated `datetime.datetime` mean?

I want `msgspec` to make it easy to do the right thing, and (within reason) possible to do the flexible thing. As such, I'd argue that raw `datetime.datetime` and `datetime.time` annotations should only decode timezone-aware objects. This means that by default APIs built with msgspec are compatible with `json-schema` (which lacks a naive datetime/time format), and common web languages like golang (which requires RFC3339 compatible strings in JSON by default).

To support naive-datetime or any-datetime types, we'd add a new config to `Meta` annotations. Something like:

```python
from msgspec import Struct, Meta
from typing import Annotated
import datetime

class Example(Struct):
    aware_only_datetime: datetime.datetime
    aware_only_time: datetime.time
    date: datetime.date  # date objects have no timezone
    naive_only_datetime: Annotated[datetime.datetime, Meta(timezone=False)]
    naive_only_time: Annotated[datetime.time, Meta(timezone=False)]
    any_datetime: Annotated[datetime.datetime, Meta(timezone=None)]
    any_time: Annotated[datetime.time, Meta(timezone=None)]
```

Like above, I don't love the `timezone=True` (aware), `timezone=False` (naive), `timezone=None` (aware or naive) syntax, if anyone can think of a better API spelling please let me know.

We could also add type aliases in a new submodule `msgspec.types` to make this easier to spell (since datetimes are common):

```python
from msgspec import Struct
from msgspec.types import NaiveDatetime

# NaiveDatetime = Annotated[datetime.datetime, Meta(timezone=False)]

class Example(Struct):
    naive_only_datetime: NaiveDatetime
```

## Msgpack Complications

Currently we use msgpack's timestamp extension (https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type) when encoding datetimes to msgpack. This extension by design only supports timezone-aware datetimes. `msgpack` has no standard representation for naive datetimes (or time/date objects in general). To handle this, I plan to encode naive datetimes as strings in the same format as JSON. This is an edge case that I don't expect to affect most users. I think the main benefit of supporting it is parity between types supported by both protocols.

Pull request:
Support encoding/decoding time objects
Final part of #220. Adds support for encoding and decoding `datetime.time` objects, following the same rules as `datetime.datetime` objects.

Fixes #220.

Patch:
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 69e8b26..65eb496 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -114,6 +114,7 @@ Most combinations of the following types are supported (see
 - `frozenset` / `typing.FrozenSet`
 - `datetime.datetime`
 - `datetime.date`
+- `datetime.time`
 - `uuid.UUID`
 - `typing.Any`
 - `typing.Optional`
@@ -368,7 +369,7 @@ timezone-naive by specifying a ``tz`` constraint (see
     >>> msgspec.json.decode(msg, type=datetime.datetime)
     datetime.datetime(2021, 4, 2, 18, 18, 10, 123)
 
-    >>> msgspec.json.decode(b'"oops not a date"', type=datetime.datetime)
+    >>> msgspec.json.decode(b'"oops"', type=datetime.datetime)
     Traceback (most recent call last):
       File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Invalid RFC3339 encoded datetime
@@ -392,11 +393,60 @@ timezone-naive by specifying a ``tz`` constraint (see
     >>> msgspec.json.decode(msg, type=datetime.date)
     datetime.date(2021, 4, 2)
 
-    >>> msgspec.json.decode(b'"oops not a date"', type=datetime.date)
+    >>> msgspec.json.decode(b'"oops"', type=datetime.date)
     Traceback (most recent call last):
       File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Invalid RFC3339 encoded date
 
+``time``
+~~~~~~~~~~~~
+
+The encoding used for `datetime.time` objects is dependent on whether these
+objects are timezone-aware_ or timezone-naive:
+
+- Timezone-aware times are encoded as RFC3339_ compatible strings. Note that
+  during decoding all timezones are normalized to UTC.
+
+- Timezone-naive times are encoded as ISO8601_ compatible strings (*almost*
+  RFC3339_ compatible) in both protocols. The lack of a timezone component
+  means they're not strictly RFC3339_ compatible, but are still ISO8601_
+  compatible.
+
+Note that you can require a `datetime.time` object to be timezone-aware or
+timezone-naive by specifying a ``tz`` constraint (see
+:ref:`datetime-constraints` for more information).
+
+.. code-block:: python
+
+    >>> import datetime
+
+    >>> tz = datetime.timezone(datetime.timedelta(hours=-6))
+
+    >>> tz_aware = datetime.time(18, 18, 10, 123, tzinfo=tz)
+
+    >>> msg = msgspec.json.encode(tz_aware)
+
+    >>> msg
+    b'"18:18:10.000123-06:00"'
+
+    >>> msgspec.json.decode(msg, type=datetime.time)
+    datetime.time(0, 18, 10, 123, tzinfo=datetime.timezone.utc)
+
+    >>> tz_naive = datetime.time(18, 18, 10, 123)
+
+    >>> msg = msgspec.json.encode(tz_naive)
+
+    >>> msg
+    b'"18:18:10.000123"'
+
+    >>> msgspec.json.decode(msg, type=datetime.time)
+    datetime.time(18, 18, 10, 123)
+
+    >>> msgspec.json.decode(b'"oops"', type=datetime.time)
+    Traceback (most recent call last):
+      File "<stdin>", line 1, in <module>
+    msgspec.ValidationError: Invalid RFC3339 encoded time
+
 ``uuid``
 ~~~~~~~~
 
@@ -416,7 +466,7 @@ timezone-naive by specifying a ``tz`` constraint (see
     >>> msgspec.json.decode(msg, type=uuid.UUID)
     UUID('c4524ac0-e81e-4aa8-a595-0aec605a659a')
 
-    >>> msgspec.json.decode(b'"oops not a uuid"', type=uuid.UUID)
+    >>> msgspec.json.decode(b'"oops"', type=uuid.UUID)
     Traceback (most recent call last):
         File "<stdin>", line 1, in <module>
     msgspec.ValidationError: Invalid UUID
diff --git a/msgspec/_core.c b/msgspec/_core.c
index 6749b2b..3cc9914 100644
--- a/msgspec/_core.c
+++ b/msgspec/_core.c
@@ -2329,6 +2329,9 @@ typenode_simple_repr(TypeNode *self) {
     if (self->types & MS_TYPE_DATE) {
         if (!strbuilder_extend_literal(&builder, "date")) return NULL;
     }
+    if (self->types & MS_TYPE_TIME) {
+        if (!strbuilder_extend_literal(&builder, "time")) return NULL;
+    }
     if (self->types & MS_TYPE_UUID) {
         if (!strbuilder_extend_literal(&builder, "uuid")) return NULL;
     }
@@ -3047,14 +3050,14 @@ typenode_collect_check_invariants(
     if (ms_popcount(
             state->types & (
                 MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
-                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_TIME | MS_TYPE_UUID
             )
         ) > 1
     ) {
         PyErr_Format(
             PyExc_TypeError,
             "Type unions may not contain more than one str-like type (`str`, "
-            "`Enum`, `Literal[str values]`, `datetime`, `date`, `uuid`) - "
+            "`Enum`, `Literal[str values]`, `datetime`, `date`, `time`, `uuid`) - "
             "type `%R` is not supported",
             state->context
         );
@@ -3067,7 +3070,7 @@ typenode_collect_check_invariants(
             state->types & (
                 MS_TYPE_STR | MS_TYPE_STRLITERAL | MS_TYPE_ENUM |
                 MS_TYPE_BYTES | MS_TYPE_BYTEARRAY |
-                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_TIME | MS_TYPE_UUID
             )
         ) > 1
     ) {
@@ -3076,7 +3079,7 @@ typenode_collect_check_invariants(
                 PyExc_TypeError,
                 "JSON type unions may not contain more than one str-like type "
                 "(`str`, `Enum`, `Literal[str values]`, `datetime`, `date`, "
-                "`uuid`, `bytes`, `bytearray`) - type `%R` is not supported",
+                "`time`, `uuid`, `bytes`, `bytearray`) - type `%R` is not supported",
                 state->context
             );
             return -1;
@@ -3736,6 +3739,11 @@ typenode_collect_type_full(
         *kind = CK_TIME;
         return 0;
     }
+    else if (obj == (PyObject *)(PyDateTimeAPI->TimeType)) {
+        state->types |= MS_TYPE_TIME;
+        *kind = CK_TIME;
+        return 0;
+    }
     else if (obj == (PyObject *)(PyDateTimeAPI->DateType)) {
         state->types |= MS_TYPE_DATE;
         return 0;
@@ -7725,12 +7733,9 @@ ms_passes_map_constraints(Py_ssize_t size, TypeNode *type, PathNode *path) {
     return true;
 }
 
-static PyObject *
-ms_check_datetime_constraints(
-    int year, int month, int day,
-    int hour, int minute, int second, int microsecond,
-    PyObject *tz,
-    TypeNode *type, PathNode *path
+static bool
+ms_passes_tz_constraint(
+    PyObject *tz, TypeNode *type, PathNode *path
 ) {
     char *err, *type_str;
     if (tz == Py_None) {
@@ -7743,11 +7748,7 @@ ms_check_datetime_constraints(
         err = "Expected `%s` with no timezone component%U";
         goto error;
     }
-
-    return PyDateTimeAPI->DateTime_FromDateAndTime(
-        year, month, day, hour, minute, second, microsecond, tz,
-        PyDateTimeAPI->DateTimeType
-    );
+    return true;
 
 error:
     if (type->types & MS_TYPE_TIME) {
@@ -7758,7 +7759,7 @@ error:
     }
 
     ms_raise_validation_error(path, err, type_str);
-    return NULL;
+    return false;
 }
 
 static int
@@ -7777,10 +7778,13 @@ ms_encode_err_type_unsupported(PyTypeObject *type) {
 
 #define MS_HAS_TZINFO(o)  (((_PyDateTime_BaseTZInfo *)(o))->hastzinfo)
 #if PY_VERSION_HEX < 0x030a00f0
-#define MS_GET_TZINFO(o)      (MS_HAS_TZINFO(o) ? \
+#define MS_DATE_GET_TZINFO(o)      (MS_HAS_TZINFO(o) ? \
     ((PyDateTime_DateTime *)(o))->tzinfo : Py_None)
+#define MS_TIME_GET_TZINFO(o)      (MS_HAS_TZINFO(o) ? \
+    ((PyDateTime_Time *)(o))->tzinfo : Py_None)
 #else
-#define MS_GET_TZINFO(o) PyDateTime_DATE_GET_TZINFO(o)
+#define MS_DATE_GET_TZINFO(o) PyDateTime_DATE_GET_TZINFO(o)
+#define MS_TIME_GET_TZINFO(o) PyDateTime_TIME_GET_TZINFO(o)
 #endif
 
 static bool
@@ -7848,6 +7852,20 @@ datetime_apply_tz_offset(
     return -1;
 }
 
+/* Convert a *valid* time with a tz offset (in minutes) to UTC time. */
+static void
+time_apply_tz_offset(
+    int *hour, int *minute, int tz_offset
+) {
+    *minute -= tz_offset;
+    if (*minute < 0 || *minute >= 60) {
+        *hour += divmod(*minute, 60, minute);
+    }
+    if (*hour < 0 || *hour >= 24) {
+        divmod(*hour, 24, hour);
+    }
+}
+
 /* Days since 0001-01-01, the min value for python's datetime objects */
 static int
 days_since_min_datetime(int year, int month, int day)
@@ -7944,7 +7962,8 @@ datetime_from_epoch(
         years++;
     }
 
-    return ms_check_datetime_constraints(
+    if (!ms_passes_tz_constraint(PyDateTime_TimeZone_UTC, type, path)) return NULL;
+    return PyDateTimeAPI->DateTime_FromDateAndTime(
         years + 2000,
         months + 3,
         remdays + 1,
@@ -7953,8 +7972,7 @@ datetime_from_epoch(
         remsecs % 60,
         epoch_nanos / 1000,
         PyDateTime_TimeZone_UTC,
-        type,
-        path
+        PyDateTimeAPI->DateTimeType
     );
 }
 
@@ -7995,28 +8013,14 @@ ms_encode_date(EncoderState *self, PyObject *obj, char *out)
     out = ms_write_fixint(out, day, 2);
 }
 
-/* Requires 32 bytes of scratch space max.
- *
- * Returns +nbytes if successful, -1 on failure */
+/* Requires 21 bytes of scratch space */
 static int
-ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
-{
-    uint32_t year = PyDateTime_GET_YEAR(obj);
-    uint8_t month = PyDateTime_GET_MONTH(obj);
-    uint8_t day = PyDateTime_GET_DAY(obj);
-
-    uint8_t hour = PyDateTime_DATE_GET_HOUR(obj);
-    uint8_t minute = PyDateTime_DATE_GET_MINUTE(obj);
-    uint8_t second = PyDateTime_DATE_GET_SECOND(obj);
-    uint32_t microsecond = PyDateTime_DATE_GET_MICROSECOND(obj);
-
-    char *p = out;
-    p = ms_write_fixint(p, year, 4);
-    *p++ = '-';
-    p = ms_write_fixint(p, month, 2);
-    *p++ = '-';
-    p = ms_write_fixint(p, day, 2);
-    *p++ = 'T';
+ms_encode_time_parts(
+    EncoderState *self, PyObject *obj,
+    uint8_t hour, uint8_t minute, uint8_t second, uint32_t microsecond,
+    PyObject *tzinfo, char *out, int out_offset
+) {
+    char *p = out + out_offset;
     p = ms_write_fixint(p, hour, 2);
     *p++ = ':';
     p = ms_write_fixint(p, minute, 2);
@@ -8027,12 +8031,11 @@ ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
         p = ms_write_fixint(p, microsecond, 6);
     }
 
-    if (MS_HAS_TZINFO(obj)) {
+    if (tzinfo != Py_None) {
         int32_t offset_days = 0, offset_secs = 0;
-        PyObject *tzinfo = MS_GET_TZINFO(obj);
 
         if (tzinfo != PyDateTime_TimeZone_UTC) {
-            PyObject *offset = CALL_METHOD_ONE_ARG(tzinfo, self->mod->str_utcoffset, obj);
+            PyObject *offset = CALL_METHOD_ONE_ARG(tzinfo, self->mod->str_utcoffset, Py_None);
             if (offset == NULL) return -1;
             if (PyDelta_Check(offset)) {
                 offset_days = PyDateTime_DELTA_GET_DAYS(offset);
@@ -8041,7 +8044,7 @@ ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
             else if (offset != Py_None) {
                 PyErr_SetString(
                     PyExc_TypeError,
-                    "datetime.tzinfo.utcoffset returned a non-timedelta object"
+                    "tzinfo.utcoffset returned a non-timedelta object"
                 );
                 Py_DECREF(offset);
                 return -1;
@@ -8087,6 +8090,40 @@ ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
     return p - out;
 }
 
+/* Requires 21 bytes of scratch space max.
+ *
+ * Returns +nbytes if successful, -1 on failure */
+static int
+ms_encode_time(EncoderState *self, PyObject *obj, char *out)
+{
+    uint8_t hour = PyDateTime_TIME_GET_HOUR(obj);
+    uint8_t minute = PyDateTime_TIME_GET_MINUTE(obj);
+    uint8_t second = PyDateTime_TIME_GET_SECOND(obj);
+    uint32_t microsecond = PyDateTime_TIME_GET_MICROSECOND(obj);
+    PyObject *tzinfo = MS_TIME_GET_TZINFO(obj);
+    return ms_encode_time_parts(
+        self, obj, hour, minute, second, microsecond, tzinfo, out, 0
+    );
+}
+
+/* Requires 32 bytes of scratch space max.
+ *
+ * Returns +nbytes if successful, -1 on failure */
+static int
+ms_encode_datetime(EncoderState *self, PyObject *obj, char *out)
+{
+    uint8_t hour = PyDateTime_DATE_GET_HOUR(obj);
+    uint8_t minute = PyDateTime_DATE_GET_MINUTE(obj);
+    uint8_t second = PyDateTime_DATE_GET_SECOND(obj);
+    uint32_t microsecond = PyDateTime_DATE_GET_MICROSECOND(obj);
+    PyObject *tzinfo = MS_DATE_GET_TZINFO(obj);
+    ms_encode_date(self, obj, out);
+    out[10] = 'T';
+    return ms_encode_time_parts(
+        self, obj, hour, minute, second, microsecond, tzinfo, out, 11
+    );
+}
+
 static PyObject *
 ms_decode_date(const char *buf, Py_ssize_t size, PathNode *path) {
     int year, month, day;
@@ -8112,6 +8149,116 @@ invalid:
     return ms_error_with_path("Invalid RFC3339 encoded date%U", path);
 }
 
+static PyObject *
+ms_decode_time(const char *buf, Py_ssize_t size, TypeNode *type, PathNode *path) {
+    int hour, minute, second, microsecond = 0, offset = 0;
+    const char *buf_end = buf + size;
+    bool round_up_micros = false;
+    PyObject *tz = Py_None;
+    char c;
+
+    /* A valid time is at least 8 characters in length */
+    if (size < 8) goto invalid;
+
+    /* Parse time */
+    if ((buf = ms_read_fixint(buf, 2, &hour)) == NULL) goto invalid;
+    if (*buf++ != ':') goto invalid;
+    if ((buf = ms_read_fixint(buf, 2, &minute)) == NULL) goto invalid;
+    if (*buf++ != ':') goto invalid;
+    if ((buf = ms_read_fixint(buf, 2, &second)) == NULL) goto invalid;
+
+    /* Remaining reads require bounds check */
+#define next_or_null() (buf == buf_end) ? '\0' : *buf++
+    c = next_or_null();
+
+    if (c == '.') {
+        int ndigits = 0;
+        while (ndigits < 6) {
+            c = next_or_null();
+            if (!is_digit(c)) goto end_decimal;
+            ndigits++;
+            microsecond = microsecond * 10 + (c - '0');
+        }
+        c = next_or_null();
+        if (is_digit(c)) {
+            /* This timestamp has higher precision than microseconds; parse
+            * the next digit to support rounding, then skip all remaining
+            * digits */
+            if ((c - '0') >= 5) {
+                round_up_micros = true;
+            }
+            while (true) {
+                c = next_or_null();
+                if (!is_digit(c)) break;
+            }
+        }
+end_decimal:
+        /* Error if no digits after decimal */
+        if (ndigits == 0) goto invalid;
+        int pow10[6] = {100000, 10000, 1000, 100, 10, 1};
+        /* Scale microseconds appropriately */
+        microsecond *= pow10[ndigits - 1];
+    }
+#undef next_or_null
+
+    /* Parse timezone */
+    if (c == 'Z' || c == 'z') {
+        tz = PyDateTime_TimeZone_UTC;
+
+        /* Check for trailing characters */
+        if (buf != buf_end) goto invalid;
+    }
+    else if (c != '\0') {
+        int offset_hour, offset_min;
+        if (c == '-') {
+            offset = -1;
+        }
+        else if (c == '+') {
+            offset = 1;
+        }
+        else {
+            goto invalid;
+        }
+
+        /* Explicit offset requires exactly 5 bytes left */
+        if (buf_end - buf != 5) goto invalid;
+
+        if ((buf = ms_read_fixint(buf, 2, &offset_hour)) == NULL) goto invalid;
+        if (*buf++ != ':') goto invalid;
+        if ((buf = ms_read_fixint(buf, 2, &offset_min)) == NULL) goto invalid;
+        if (offset_hour > 23 || offset_min > 59) goto invalid;
+        offset *= (offset_hour * 60 + offset_min);
+        tz = PyDateTime_TimeZone_UTC;
+    }
+
+    /* Ensure all numbers are valid */
+    if (hour > 23) goto invalid;
+    if (minute > 59) goto invalid;
+    if (second > 59) goto invalid;
+
+    if (MS_UNLIKELY(round_up_micros)) {
+        microsecond++;
+        if (microsecond == 1000000) {
+            microsecond = 0;
+            second++;
+            if (second == 60) {
+                second = 0;
+                offset--;
+            }
+        }
+    }
+
+    if (offset) time_apply_tz_offset(&hour, &minute, offset);
+
+    if (!ms_passes_tz_constraint(tz, type, path)) return NULL;
+    return PyDateTimeAPI->Time_FromTime(
+        hour, minute, second, microsecond, tz, PyDateTimeAPI->TimeType
+    );
+
+invalid:
+    return ms_error_with_path("Invalid RFC3339 encoded time%U", path);
+}
+
 static PyObject *
 ms_decode_datetime(const char *buf, Py_ssize_t size, TypeNode *type, PathNode *path) {
     int year, month, day, hour, minute, second, microsecond = 0, offset = 0;
@@ -8240,8 +8387,10 @@ end_decimal:
         }
     }
 
-    return ms_check_datetime_constraints(
-        year, month, day, hour, minute, second, microsecond, tz, type, path
+    if (!ms_passes_tz_constraint(tz, type, path)) return NULL;
+    return PyDateTimeAPI->DateTime_FromDateAndTime(
+        year, month, day, hour, minute, second, microsecond, tz,
+        PyDateTimeAPI->DateTimeType
     );
 
 invalid:
@@ -9073,21 +9222,29 @@ mpack_encode_date(EncoderState *self, PyObject *obj)
     return mpack_encode_cstr(self, buf, 10);
 }
 
+static int
+mpack_encode_time(EncoderState *self, PyObject *obj)
+{
+    char buf[21];
+    int size = ms_encode_time(self, obj, buf);
+    if (size < 0) return -1;
+    return mpack_encode_cstr(self, buf, size);
+}
+
 static int
 mpack_encode_datetime(EncoderState *self, PyObject *obj)
 {
     int64_t seconds;
     int32_t nanoseconds;
-    PyObject *tzinfo;
+    PyObject *tzinfo = MS_DATE_GET_TZINFO(obj);
 
-    if (!MS_HAS_TZINFO(obj)) {
+    if (tzinfo == Py_None) {
         char buf[32];
         int size = ms_encode_datetime(self, obj, buf);
         if (size < 0) return -1;
         return mpack_encode_cstr(self, buf, size);
     }
 
-    tzinfo = MS_GET_TZINFO(obj);
     if (tzinfo == PyDateTime_TimeZone_UTC) {
         datetime_to_epoch(obj, &seconds, &nanoseconds);
     }
@@ -9183,6 +9340,9 @@ mpack_encode(EncoderState *self, PyObject *obj)
     else if (type == PyDateTimeAPI->DateType) {
         return mpack_encode_date(self, obj);
     }
+    else if (type == PyDateTimeAPI->TimeType) {
+        return mpack_encode_time(self, obj);
+    }
     else if (type == &Ext_Type) {
         return mpack_encode_ext(self, obj);
     }
@@ -9605,6 +9765,17 @@ json_encode_date(EncoderState *self, PyObject *obj)
     return ms_write(self, buf, 12);
 }
 
+static int
+json_encode_time(EncoderState *self, PyObject *obj)
+{
+    char buf[23];
+    buf[0] = '"';
+    int size = ms_encode_time(self, obj, buf + 1);
+    if (size < 0) return -1;
+    buf[size + 1] = '"';
+    return ms_write(self, buf, size + 2);
+}
+
 static int
 json_encode_datetime(EncoderState *self, PyObject *obj)
 {
@@ -9968,6 +10139,9 @@ json_encode(EncoderState *self, PyObject *obj)
     else if (type == PyDateTimeAPI->DateType) {
         return json_encode_date(self, obj);
     }
+    else if (type == PyDateTimeAPI->TimeType) {
+        return json_encode_time(self, obj);
+    }
     else if (type == &PyBytes_Type) {
         return json_encode_bytes(self, obj);
     }
@@ -10661,7 +10835,7 @@ mpack_decode_str(DecoderState *self, Py_ssize_t size, TypeNode *type, PathNode *
     if (MS_LIKELY(
             type->types & (
                 MS_TYPE_ANY | MS_TYPE_STR | MS_TYPE_ENUM | MS_TYPE_STRLITERAL |
-                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_TIME | MS_TYPE_UUID
             )
         )
     ) {
@@ -10676,6 +10850,9 @@ mpack_decode_str(DecoderState *self, Py_ssize_t size, TypeNode *type, PathNode *
         if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(s, size, path);
         }
+        if (MS_UNLIKELY(type->types & MS_TYPE_TIME)) {
+            return ms_decode_time(s, size, type, path);
+        }
         if (MS_UNLIKELY(type->types & MS_TYPE_UUID)) {
             return ms_decode_uuid(s, size, path);
         }
@@ -12704,7 +12881,7 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
             type->types & (
                 MS_TYPE_ANY | MS_TYPE_STR | MS_TYPE_ENUM | MS_TYPE_STRLITERAL |
                 MS_TYPE_BYTES | MS_TYPE_BYTEARRAY |
-                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_UUID
+                MS_TYPE_DATETIME | MS_TYPE_DATE | MS_TYPE_TIME | MS_TYPE_UUID
             )
         )
     ) {
@@ -12729,6 +12906,9 @@ json_decode_string(JSONDecoderState *self, TypeNode *type, PathNode *path) {
         else if (MS_UNLIKELY(type->types & MS_TYPE_DATE)) {
             return ms_decode_date(view, size, path);
         }
+        else if (MS_UNLIKELY(type->types & MS_TYPE_TIME)) {
+            return ms_decode_time(view, size, type, path);
+        }
         else if (MS_UNLIKELY(type->types & MS_TYPE_UUID)) {
             return ms_decode_uuid(view, size, path);
         }
diff --git a/msgspec/_utils.py b/msgspec/_utils.py
index 29f0f33..be34c43 100644
--- a/msgspec/_utils.py
+++ b/msgspec/_utils.py
@@ -379,10 +379,15 @@ class SchemaBuilder:
                 schema["multipleOf"] = meta.multiple_of
             if meta.pattern is not None:
                 schema["pattern"] = meta.pattern
-            if meta.tz is True:
-                if t is datetime.datetime:
+            if t is datetime.datetime:
+                if meta.tz is True:
                     schema["format"] = "date-time"
-            if t is str:
+            elif t is datetime.time:
+                if meta.tz is True:
+                    schema["format"] = "time"
+                elif meta.tz is False:
+                    schema["format"] = "partial-time"
+            elif t is str:
                 if meta.max_length is not None:
                     schema["maxLength"] = meta.max_length
                 if meta.min_length is not None:
@@ -433,6 +438,10 @@ class SchemaBuilder:
             schema["type"] = "string"
             schema["contentEncoding"] = "base64"
         elif t is datetime.datetime:
+            # When possible, format is set in _process_metadata
+            schema["type"] = "string"
+        elif t is datetime.time:
+            # When possible, format is set in _process_metadata
             schema["type"] = "string"
         elif t is datetime.date:
             schema["type"] = "string"


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index 4c17e05..fb69f08 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -26,6 +26,8 @@ import msgspec
 
 from utils import temp_module
 
+UTC = datetime.timezone.utc
+
 PY310 = sys.version_info[:2] >= (3, 10)
 PY311 = sys.version_info[:2] >= (3, 11)
 
@@ -688,15 +690,22 @@ class TestUnionTypeErrors:
         assert repr(typ) in str(rec.value)
 
     @pytest.mark.parametrize(
-        "types",
-        [(FruitStr, str), (FruitStr, Literal["one", "two"]), (FruitStr, datetime.date)],
+        "typ",
+        [
+            str,
+            Literal["one", "two"],
+            datetime.datetime,
+            datetime.date,
+            datetime.time,
+            uuid.UUID,
+        ],
     )
-    def test_err_union_with_multiple_str_like_types(self, types, proto):
-        typ = Union[types]
+    def test_err_union_with_multiple_str_like_types(self, typ, proto):
+        union = Union[FruitStr, typ]
         with pytest.raises(TypeError) as rec:
-            proto.Decoder(typ)
+            proto.Decoder(union)
         assert "str-like" in str(rec.value)
-        assert repr(typ) in str(rec.value)
+        assert repr(union) in str(rec.value)
 
     @pytest.mark.parametrize(
         "typ,kind",
@@ -1991,6 +2000,222 @@ class TestDate:
             proto.decode(msg, type=datetime.date)
 
 
+class TestTime:
+    @staticmethod
+    def parse(t_str):
+        t_str = t_str.replace("Z", "+00:00")
+        t = datetime.time.fromisoformat(t_str)
+        if t.tzinfo is not None:
+            offset = t.tzinfo.utcoffset(None)
+            if offset:
+                dt = datetime.datetime.combine(datetime.date(2000, 1, 1), t)
+                t = (dt - offset).time()
+            return t.replace(tzinfo=datetime.timezone.utc)
+        return t
+
+    @pytest.mark.parametrize(
+        "t",
+        [
+            "00:00:00",
+            "01:02:03",
+            "01:02:03.000004",
+            "12:34:56.789000",
+            "23:59:59.999999",
+        ],
+    )
+    def test_encode_time_naive(self, proto, t):
+        res = proto.encode(self.parse(t))
+        sol = proto.encode(t)
+        assert res == sol
+
+    @pytest.mark.parametrize(
+        "t",
+        [
+            "00:00:00",
+            "01:02:03",
+            "01:02:03.000004",
+            "12:34:56.789000",
+            "23:59:59.999999",
+        ],
+    )
+    def test_decode_time_naive(self, proto, t):
+        sol = self.parse(t)
+        res = proto.decode(proto.encode(t), type=datetime.time)
+        assert type(res) is datetime.time
+        assert res == sol
+
+    def test_decode_time_wrong_type(self, proto):
+        msg = proto.encode([])
+        with pytest.raises(
+            msgspec.ValidationError, match="Expected `time`, got `array`"
+        ):
+            proto.decode(msg, type=datetime.time)
+
+    @pytest.mark.parametrize(
+        "offset",
+        [
+            datetime.timedelta(0),
+            datetime.timedelta(days=1, microseconds=-1),
+            datetime.timedelta(days=-1, microseconds=1),
+            datetime.timedelta(days=1, seconds=-29),
+            datetime.timedelta(days=-1, seconds=29),
+            datetime.timedelta(days=0, seconds=30),
+            datetime.timedelta(days=0, seconds=-30),
+        ],
+    )
+    def test_encode_time_offset_is_appx_equal_to_utc(self, proto, offset):
+        x = datetime.time(14, 56, 27, 123456, datetime.timezone(offset))
+        res = proto.encode(x)
+        sol = proto.encode("14:56:27.123456Z")
+        assert res == sol
+
+    @pytest.mark.parametrize(
+        "offset, t_str",
+        [
+            (
+                datetime.timedelta(days=1, seconds=-30),
+                "14:56:27.123456+23:59",
+            ),
+            (
+                datetime.timedelta(days=-1, seconds=30),
+                "14:56:27.123456-23:59",
+            ),
+            (
+                datetime.timedelta(minutes=19, seconds=32, microseconds=130000),
+                "14:56:27.123456+00:20",
+            ),
+        ],
+    )
+    def test_encode_time_offset_rounds_to_nearest_minute(self, proto, offset, t_str):
+        x = datetime.time(14, 56, 27, 123456, datetime.timezone(offset))
+        res = proto.encode(x)
+        sol = proto.encode(t_str)
+        assert res == sol
+
+    @pytest.mark.parametrize(
+        "dt",
+        [
+            "04:05:06.000007",
+            "04:05:06.007",
+            "04:05:06",
+            "21:19:22.123456",
+        ],
+    )
+    @pytest.mark.parametrize("suffix", ["", "Z", "+00:00", "-00:00"])
+    def test_decode_time_utc(self, proto, dt, suffix):
+        dt += suffix
+        sol = self.parse(dt)
+        msg = proto.encode(sol)
+        res = proto.decode(msg, type=datetime.time)
+        assert res == sol
+
+    @pytest.mark.parametrize("t", ["00:00:01", "12:01:01"])
+    @pytest.mark.parametrize("sign", ["-", "+"])
+    @pytest.mark.parametrize("hour", [0, 8, 12, 16, 23])
+    @pytest.mark.parametrize("minute", [0, 30])
+    def test_decode_time_with_timezone(self, proto, t, sign, hour, minute):
+        s = f"{t}{sign}{hour:02}:{minute:02}"
+        msg = proto.encode(s)
+        res = proto.decode(msg, type=datetime.time)
+        sol = self.parse(s)
+        assert res == sol
+
+    @pytest.mark.parametrize("z", ["Z", "z"])
+    def test_decode_time_not_case_sensitive(self, proto, z):
+        """Z can be upper/lowercase"""
+        sol = datetime.time(4, 5, 6, 7, UTC)
+        res = proto.decode(proto.encode(f"04:05:06.000007{z}"), type=datetime.time)
+        assert res == sol
+
+    @pytest.mark.parametrize(
+        "t, sol",
+        [
+            (
+                "03:04:05.1234564Z",
+                datetime.time(3, 4, 5, 123456, UTC),
+            ),
+            (
+                "03:04:05.1234565Z",
+                datetime.time(3, 4, 5, 123457, UTC),
+            ),
+            (
+                "03:04:05.12345650000000000001Z",
+                datetime.time(3, 4, 5, 123457, UTC),
+            ),
+            (
+                "03:04:05.9999995Z",
+                datetime.time(3, 4, 6, 0, UTC),
+            ),
+            (
+                "03:04:59.9999995Z",
+                datetime.time(3, 5, 0, 0, UTC),
+            ),
+            (
+                "03:59:59.9999995Z",
+                datetime.time(4, 0, 0, 0, UTC),
+            ),
+            (
+                "23:59:59.9999995Z",
+                datetime.time(0, 0, 0, 0, UTC),
+            ),
+        ],
+    )
+    def test_decode_time_nanos(self, proto, t, sol):
+        msg = proto.encode(t)
+        res = proto.decode(msg, type=datetime.time)
+        assert res == sol
+
+    @pytest.mark.parametrize(
+        "s",
+        [
+            # Incorrect field lengths
+            "1:02:03.0000004Z",
+            "01:2:03.0000004Z",
+            "01:02:3.0000004Z",
+            "01:02:03.0000004+5:06",
+            "01:02:03.0000004+05:6",
+            # Trailing data
+            "01:02:030",
+            "01:02:03a",
+            "01:02:03.a",
+            "01:02:03.0a",
+            "01:02:03.0000004a",
+            "01:02:03.0000004+00:000",
+            "01:02:03.0000004Z0",
+            # Truncated
+            "01:02:3",
+            # Missing +/-
+            "01:02:0300:00",
+            # Missing digits after decimal
+            "01:02:03.",
+            "01:02:03.Z",
+            # Invalid characters
+            "0a:02:03.004+05:06",
+            "01:0a:03.004+05:06",
+            "01:02:0a.004+05:06",
+            "01:02:03.00a+05:06",
+            "01:02:03.004+0a:06",
+            "01:02:03.004+05:0a",
+            # Hour out of range
+            "24:02:03.004",
+            # Minute out of range
+            "01:60:03.004",
+            # Second out of range
+            "01:02:60.004",
+            # Timezone hour out of range
+            "01:02:03.004+24:00",
+            "01:02:03.004-24:00",
+            # Timezone minute out of range
+            "01:02:03.004+00:60",
+            "01:02:03.004-00:60",
+        ],
+    )
+    def test_decode_time_malformed(self, proto, s):
+        msg = proto.encode(s)
+        with pytest.raises(msgspec.ValidationError, match="Invalid RFC3339"):
+            proto.decode(msg, type=datetime.time)
+
+
 class TestUUID:
     def test_encode_uuid(self, proto):
         u = uuid.uuid4()
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
index 1fab54f..bcc5f29 100644
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -582,6 +582,52 @@ class TestDateTimeConstraints:
             self.roundtrip(proto, Ex, False, as_str)
 
 
+class TestTimeConstraints:
+    @staticmethod
+    def roundtrip(proto, cls, aware, as_str):
+        dt = datetime.datetime.now(datetime.timezone.utc if aware else None).timetz()
+
+        if as_str:
+            s = proto.encode(cls(dt.isoformat()))
+        else:
+            s = proto.encode(cls(dt))
+
+        res = proto.decode(s, type=cls)
+        assert res.x == dt
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_none(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.time, Meta(tz=None)]
+
+        self.roundtrip(proto, Ex, True, as_str)
+        self.roundtrip(proto, Ex, False, as_str)
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_false(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.time, Meta(tz=False)]
+
+        self.roundtrip(proto, Ex, False, as_str)
+
+        err_msg = r"Expected `time` with no timezone component - at `\$.x`"
+
+        with pytest.raises(msgspec.ValidationError, match=err_msg):
+            self.roundtrip(proto, Ex, True, as_str)
+
+    @pytest.mark.parametrize("as_str", [True, False])
+    def test_tz_true(self, proto, as_str):
+        class Ex(msgspec.Struct):
+            x: Annotated[datetime.time, Meta(tz=True)]
+
+        self.roundtrip(proto, Ex, True, as_str)
+
+        err_msg = r"Expected `time` with a timezone component - at `\$.x`"
+
+        with pytest.raises(msgspec.ValidationError, match=err_msg):
+            self.roundtrip(proto, Ex, False, as_str)
+
+
 class TestBytesConstraints:
     @pytest.mark.parametrize("typ", [bytes, bytearray])
     def test_min_length(self, proto, typ):
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 82df954..1abd8ba 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -120,16 +120,35 @@ def test_binary(typ):
 
 
 @pytest.mark.parametrize(
-    "get_type, extra",
+    "annot, extra",
     [
-        (lambda: datetime.datetime, {}),
-        (lambda: Annotated[datetime.datetime, Meta(tz=None)], {}),
-        (lambda: Annotated[datetime.datetime, Meta(tz=True)], {"format": "date-time"}),
-        (lambda: Annotated[datetime.datetime, Meta(tz=False)], {}),
+        (None, {}),
+        (Meta(tz=None), {}),
+        (Meta(tz=True), {"format": "date-time"}),
+        (Meta(tz=False), {}),
     ],
 )
-def test_datetime(get_type, extra):
-    assert msgspec.json.schema(get_type()) == {"type": "string", **extra}
+def test_datetime(annot, extra):
+    typ = datetime.datetime
+    if annot is not None:
+        typ = Annotated[typ, annot]
+    assert msgspec.json.schema(typ) == {"type": "string", **extra}
+
+
+@pytest.mark.parametrize(
+    "annot, extra",
+    [
+        (None, {}),
+        (Meta(tz=None), {}),
+        (Meta(tz=True), {"format": "time"}),
+        (Meta(tz=False), {"format": "partial-time"}),
+    ],
+)
+def test_time(annot, extra):
+    typ = datetime.time
+    if annot is not None:
+        typ = Annotated[typ, annot]
+    assert msgspec.json.schema(typ) == {"type": "string", **extra}
 
 
 def test_date():