Skip to content

Commit 39c00d5

Browse files
committed
Support casting of datetime with timezone information
Before these changes, nanobind would silently accept datetime objects with timezone information and treat them as local time.
1 parent c4291a7 commit 39c00d5

File tree

4 files changed

+119
-9
lines changed

4 files changed

+119
-9
lines changed

docs/api_extra.rst

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,10 +1164,11 @@ to a Python :py:class:`~datetime.datetime` object; other clocks may convert to
11641164

11651165
The first clock defined by the standard is ``std::chrono::system_clock``.
11661166
This clock measures the current date and time, much like the Python
1167-
:py:func:`time.time` function. It can change abruptly due to
1168-
administrative actions, daylight savings time transitions, or
1169-
synchronization with an external time server. That makes this clock a
1170-
poor choice for timing purposes, but a good choice for wall-clock time.
1167+
:py:func:`time.time` function. While it tracks elapsed time since 1970, in
1168+
the UTC timezone, It can change abruptly due to administrative actions,
1169+
leap seconds changes or synchronization with an external time server.
1170+
That makes this clock a poor choice for timing purposes,
1171+
but a good choice for wall-clock time.
11711172

11721173
The second clock defined by the standard is ``std::chrono::steady_clock``.
11731174
This clock ticks at a steady rate and is never adjusted, like
@@ -1204,6 +1205,8 @@ converting to Python.
12041205
:py:class:`~datetime.datetime` instance. The result describes a time in the
12051206
local timezone, but does not have any timezone information
12061207
attached to it (it is a naive datetime object).
1208+
Note that some time conversion can occur if your system is not set to use the
1209+
UTC timezone as the C++ and Python types follow different conventions.
12071210

12081211
- ``std::chrono::duration`` → :py:class:`datetime.timedelta`
12091212
A duration will be converted to a Python :py:class:`~datetime.timedelta`.
@@ -1219,9 +1222,11 @@ converting to Python.
12191222
- :py:class:`datetime.datetime` or :py:class:`datetime.date` or :py:class:`datetime.time` → ``std::chrono::system_clock::time_point``
12201223
A Python date, time, or datetime object can be converted into a
12211224
system clock timepoint. A :py:class:`~datetime.time` with no date
1222-
information is treated as that time on January 1, 1970. A
1223-
:py:class:`~datetime.date` with no time information is treated as midnight
1224-
on that date. **Any timezone information is ignored.**
1225+
information is treated as that time on January 1, 1970 (timezone information
1226+
is ignored). A :py:class:`~datetime.date` with no time information is treated
1227+
as midnight on that date. A :py:class:`~datetime.datetime` is treated as a
1228+
local time if no timezone information is provided. Otherwise, it is treated as
1229+
a time in its associated tzinfo.
12251230

12261231
- :py:class:`datetime.timedelta` → ``std::chrono::duration``
12271232
A Python time delta object can be converted into a duration

include/nanobind/stl/chrono.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,13 @@ class type_caster<std::chrono::time_point<std::chrono::system_clock, Duration>>
153153
ch::microseconds msecs;
154154
int yy, mon, dd, hh, min, ss, uu;
155155
try {
156-
if (!unpack_datetime(src.ptr(), &yy, &mon, &dd,
157-
&hh, &min, &ss, &uu)) {
156+
PyObject* shifted_time_ptr{};
157+
if (!shift_to_timezone(src.ptr(), &shifted_time_ptr)) {
158+
return false;
159+
}
160+
161+
if (!unpack_datetime(bool(shifted_time_ptr) ? steal(shifted_time_ptr).ptr() : src.ptr(),
162+
&yy, &mon, &dd, &hh, &min, &ss, &uu)) {
158163
return false;
159164
}
160165
} catch (python_error& e) {

include/nanobind/stl/detail/chrono.h

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,26 @@ NB_NOINLINE inline bool unpack_timedelta(PyObject *o, int *days,
144144
return false;
145145
}
146146

147+
NB_NOINLINE inline bool shift_to_timezone(PyObject *o, PyObject** shifted_o, PyObject* tz = Py_None) {
148+
*shifted_o = nullptr;
149+
150+
datetime_types.ensure_ready();
151+
if (!PyType_IsSubtype(Py_TYPE(o), (PyTypeObject*) datetime_types.datetime.ptr()) ||
152+
PyObject_GetAttrString(o, "tzinfo") == Py_None) {
153+
return true;
154+
}
155+
156+
try {
157+
handle shifted_datetime = borrow(o).attr("astimezone")(borrow(tz)).release();
158+
*shifted_o = shifted_datetime.ptr();
159+
} catch (python_error& e) {
160+
e.restore();
161+
return false;
162+
}
163+
164+
return true;
165+
}
166+
147167
NB_NOINLINE inline bool unpack_datetime(PyObject *o,
148168
int *year, int *month, int *day,
149169
int *hour, int *minute, int *second,
@@ -229,6 +249,41 @@ NB_NOINLINE inline bool unpack_timedelta(PyObject *o, int *days,
229249
return false;
230250
}
231251

252+
NB_NOINLINE inline bool shift_to_timezone(PyObject *o, PyObject** shifted_o, PyObject* tz = Py_None) {
253+
if (!PyDateTimeAPI) {
254+
PyDateTime_IMPORT;
255+
if (!PyDateTimeAPI)
256+
raise_python_error();
257+
}
258+
259+
*shifted_o = nullptr;
260+
261+
if (!PyDateTime_Check(o)) {
262+
return true;
263+
}
264+
265+
#if PY_VERSION_HEX < 0x03090000
266+
object tzinfo = borrow(o).attr("tzinfo");
267+
if (tzinfo.is_none()) {
268+
return true;
269+
}
270+
#else
271+
if (PyDateTime_DATE_GET_TZINFO(o) == Py_None) {
272+
return true;
273+
}
274+
#endif
275+
276+
try {
277+
handle shifted_datetime = borrow(o).attr("astimezone")(borrow(tz)).release();
278+
*shifted_o = shifted_datetime.ptr();
279+
} catch (python_error& e) {
280+
e.restore();
281+
return false;
282+
}
283+
284+
return true;
285+
}
286+
232287
NB_NOINLINE inline bool unpack_datetime(PyObject *o,
233288
int *year, int *month, int *day,
234289
int *hour, int *minute, int *second,

tests/test_chrono.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
import pytest
1010

11+
# tzset is unix-only but necessary after changing timezones
12+
try:
13+
from time import tzset
14+
except ImportError:
15+
def tzset():
16+
pass
17+
1118

1219
def test_chrono_system_clock():
1320
# Get the time from both c++ and datetime
@@ -73,6 +80,12 @@ def test_chrono_system_clock_roundtrip_date():
7380
assert time2.microsecond == 0
7481

7582

83+
SKIP_TZ_NO_ZONEINFO = pytest.mark.skipif(
84+
"sys.version_info < (3, 9)",
85+
reason="requires python3.9 or higher"
86+
)
87+
88+
7689
SKIP_TZ_ENV_ON_WIN = pytest.mark.skipif(
7790
"sys.platform == 'win32'",
7891
reason="TZ environment variable only supported on POSIX"
@@ -104,6 +117,7 @@ def test_chrono_system_clock_roundtrip_date():
104117
def test_chrono_system_clock_roundtrip_time(time1, tz, monkeypatch):
105118
if tz is not None:
106119
monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{tz}")
120+
tzset()
107121

108122
# Roundtrip the time
109123
datetime2 = m.test_chrono2(time1)
@@ -263,6 +277,37 @@ def test_chrono_misc():
263277
assert roundtrip_datetime(d2) == d2
264278

265279

280+
281+
@SKIP_TZ_ENV_ON_WIN
282+
@SKIP_TZ_NO_ZONEINFO
283+
@pytest.mark.parametrize(
284+
"tz",
285+
[
286+
None,
287+
pytest.param("UTC"),
288+
pytest.param("America/New_York"),
289+
],
290+
)
291+
@pytest.mark.parametrize(
292+
"sys_tz",
293+
[
294+
None,
295+
pytest.param("UTC"),
296+
pytest.param("America/New_York"),
297+
]
298+
)
299+
def test_chrono_tz_aware(sys_tz, tz, monkeypatch):
300+
if sys_tz is not None:
301+
monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{sys_tz}")
302+
tzset()
303+
from datetime import datetime, timedelta, timezone
304+
from zoneinfo import ZoneInfo
305+
roundtrip_datetime = m.test_nano_timepoint_roundtrip
306+
now = datetime.now()
307+
now_tz = now.astimezone(ZoneInfo(tz) if tz is not None else None)
308+
assert roundtrip_datetime(now) == roundtrip_datetime(now_tz)
309+
310+
266311
@pytest.mark.parametrize(
267312
"test_type,roundtrip_name",
268313
[

0 commit comments

Comments
 (0)