Skip to content

Commit

Permalink
Fix JSON encoding of datetimes with zoneinfo tzinfos
Browse files Browse the repository at this point in the history
This fixes a bug where datetime objects with `zoneinfo.ZoneInfo`
instances set for `tzinfo` would encode as UTC, ignoring the zoneinfo.
This issue was due to `datetime.utcoffset` being called with `None`
instead of the `datetime` instance. This worked fine for other `tzinfo`
types (like `datetime.timezone`) since those use fixed offsets that
aren't date aware.

This also fixes a bug where `datetime.time` instances with a `tzinfo`
that returns `None` from `datetime.time.utcoffset` were treated as UTC
rather than naive. Quoting from the standard library docs:

> A datetime object d is aware if both of the following hold:
>
> 1. d.tzinfo is not None
> 2. d.tzinfo.utcoffset(d) does not return None
>
> Otherwise, d is naive.
>
> A time object t is aware if both of the following hold:
>
> 1. t.tzinfo is not None
> 2. t.tzinfo.utcoffset(None) does not return None.
>
> Otherwise, t is naive.

Both bugs fixed above were due to incorrect behavior around the 2nd
condition using `utcoffset`.
  • Loading branch information
jcrist committed Aug 24, 2023
1 parent b37f0c5 commit 5414de7
Show file tree
Hide file tree
Showing 3 changed files with 45 additions and 6 deletions.
22 changes: 16 additions & 6 deletions msgspec/_core.c
Original file line number Diff line number Diff line change
Expand Up @@ -9864,7 +9864,7 @@ ms_encode_date(PyObject *obj, char *out)
/* Requires 21 bytes of scratch space */
static int
ms_encode_time_parts(
MsgspecState *mod, PyObject *obj,
MsgspecState *mod, PyObject *datetime_or_none,
uint8_t hour, uint8_t minute, uint8_t second, uint32_t microsecond,
PyObject *tzinfo, char *out, int out_offset
) {
Expand All @@ -9883,11 +9883,20 @@ ms_encode_time_parts(
int32_t offset_days = 0, offset_secs = 0;

if (tzinfo != PyDateTime_TimeZone_UTC) {
PyObject *offset = CALL_METHOD_ONE_ARG(tzinfo, mod->str_utcoffset, Py_None);
if (offset == NULL) return -1;
if (PyDelta_Check(offset)) {
PyObject *offset = CALL_METHOD_ONE_ARG(
tzinfo, mod->str_utcoffset, datetime_or_none
);
if (offset == NULL) {
return -1;
}
else if (PyDelta_Check(offset)) {
offset_days = PyDateTime_DELTA_GET_DAYS(offset);
offset_secs = PyDateTime_DELTA_GET_SECONDS(offset);
Py_DECREF(offset);
}
else if (offset == Py_None) {
Py_DECREF(offset);
goto done;
}
else if (offset != Py_None) {
PyErr_SetString(
Expand All @@ -9897,7 +9906,6 @@ ms_encode_time_parts(
Py_DECREF(offset);
return -1;
}
Py_DECREF(offset);
}
if (MS_LIKELY(offset_secs == 0)) {
*p++ = 'Z';
Expand Down Expand Up @@ -9935,6 +9943,8 @@ ms_encode_time_parts(
}
}
}

done:
return p - out;
}

Expand All @@ -9950,7 +9960,7 @@ ms_encode_time(MsgspecState *mod, PyObject *obj, char *out)
uint32_t microsecond = PyDateTime_TIME_GET_MICROSECOND(obj);
PyObject *tzinfo = MS_TIME_GET_TZINFO(obj);
return ms_encode_time_parts(
mod, obj, hour, minute, second, microsecond, tzinfo, out, 0
mod, Py_None, hour, minute, second, microsecond, tzinfo, out, 0
);
}

Expand Down
12 changes: 12 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3164,6 +3164,18 @@ def test_encode_time_offset_rounds_to_nearest_minute(self, proto, offset, t_str)
sol = proto.encode(t_str)
assert res == sol

@py39_plus
def test_encode_time_zoneinfo(self):
import zoneinfo

try:
x = datetime.time(1, 2, 3, 456789, zoneinfo.ZoneInfo("America/Chicago"))
except zoneinfo.ZoneInfoNotFoundError:
pytest.skip(reason="Failed to load timezone")
sol = msgspec.json.encode(x.isoformat())
res = msgspec.json.encode(x)
assert res == sol

@pytest.mark.parametrize(
"dt",
[
Expand Down
17 changes: 17 additions & 0 deletions tests/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@

UTC = datetime.timezone.utc

PY39 = sys.version_info[:2] >= (3, 9)
py39_plus = pytest.mark.skipif(not PY39, reason="3.9+ only")


class FruitInt(enum.IntEnum):
APPLE = -1
Expand Down Expand Up @@ -838,6 +841,20 @@ def test_encode_datetime_offset_rounds_to_nearest_minute(self, offset, expected)
s = msgspec.json.encode(x)
assert s == expected

@py39_plus
def test_encode_datetime_zoneinfo(self):
import zoneinfo

try:
x = datetime.datetime(
2023, 1, 2, 3, 4, 5, 678, zoneinfo.ZoneInfo("America/Chicago")
)
except zoneinfo.ZoneInfoNotFoundError:
pytest.skip(reason="Failed to load timezone")
sol = msgspec.json.encode(x.isoformat())
res = msgspec.json.encode(x)
assert res == sol

@pytest.mark.parametrize(
"dt",
[
Expand Down

0 comments on commit 5414de7

Please sign in to comment.