Skip to content

Commit a92d43c

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 64568cc commit a92d43c

File tree

4 files changed

+99
-9
lines changed

4 files changed

+99
-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: 44 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,30 @@ 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) || PyDateTime_DATE_GET_TZINFO(o) == Py_None) {
262+
return true;
263+
}
264+
265+
try {
266+
handle shifted_datetime = borrow(o).attr("astimezone")(borrow(tz)).release();
267+
*shifted_o = shifted_datetime.ptr();
268+
} catch (python_error& e) {
269+
e.restore();
270+
return false;
271+
}
272+
273+
return true;
274+
}
275+
232276
NB_NOINLINE inline bool unpack_datetime(PyObject *o,
233277
int *year, int *month, int *day,
234278
int *hour, int *minute, int *second,

tests/test_chrono.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ def test_chrono_system_clock_roundtrip_date():
7373
assert time2.microsecond == 0
7474

7575

76+
SKIP_TZ_NO_ZONEINFO = pytest.mark.skipif(
77+
"sys.version_info < (3, 9)",
78+
reason="requires python3.9 or higher"
79+
)
80+
81+
7682
SKIP_TZ_ENV_ON_WIN = pytest.mark.skipif(
7783
"sys.platform == 'win32'",
7884
reason="TZ environment variable only supported on POSIX"
@@ -104,6 +110,7 @@ def test_chrono_system_clock_roundtrip_date():
104110
def test_chrono_system_clock_roundtrip_time(time1, tz, monkeypatch):
105111
if tz is not None:
106112
monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{tz}")
113+
time.tzset()
107114

108115
# Roundtrip the time
109116
datetime2 = m.test_chrono2(time1)
@@ -263,6 +270,35 @@ def test_chrono_misc():
263270
assert roundtrip_datetime(d2) == d2
264271

265272

273+
274+
@pytest.mark.parametrize(
275+
"tz",
276+
[
277+
None,
278+
pytest.param("UTC", marks=SKIP_TZ_NO_ZONEINFO),
279+
pytest.param("America/New_York", marks=SKIP_TZ_NO_ZONEINFO),
280+
],
281+
)
282+
@pytest.mark.parametrize(
283+
"sys_tz",
284+
[
285+
None,
286+
pytest.param("UTC", marks=SKIP_TZ_ENV_ON_WIN),
287+
pytest.param("America/New_York", marks=SKIP_TZ_ENV_ON_WIN),
288+
]
289+
)
290+
def test_chrono_tz_aware(sys_tz, tz, monkeypatch):
291+
if sys_tz is not None:
292+
monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{sys_tz}")
293+
time.tzset()
294+
from datetime import datetime, timedelta, timezone
295+
from zoneinfo import ZoneInfo
296+
roundtrip_datetime = m.test_nano_timepoint_roundtrip
297+
now = datetime.now()
298+
now_tz = now.astimezone(ZoneInfo(tz) if tz is not None else None)
299+
assert roundtrip_datetime(now) == roundtrip_datetime(now_tz)
300+
301+
266302
@pytest.mark.parametrize(
267303
"test_type,roundtrip_name",
268304
[

0 commit comments

Comments
 (0)