diff --git a/docs/advanced/cast/chrono.rst b/docs/advanced/cast/chrono.rst index fbd46057aa..7a924dcaa4 100644 --- a/docs/advanced/cast/chrono.rst +++ b/docs/advanced/cast/chrono.rst @@ -47,7 +47,9 @@ Provided conversions - ``std::chrono::system_clock::time_point`` → ``datetime.datetime`` System clock times are converted to python datetime instances. They are in the local timezone, but do not have any timezone information attached - to them (they are naive datetime objects). + to them (they are naive datetime objects). The ``fold`` attribute + distinguishes the two occurrences of an ambiguous local time when + clocks move backward. - ``std::chrono::duration`` → ``datetime.timedelta`` Durations are converted to timedeltas, any precision in the duration @@ -62,7 +64,10 @@ Provided conversions - ``datetime.datetime`` or ``datetime.date`` or ``datetime.time`` → ``std::chrono::system_clock::time_point`` Date/time objects are converted into system clock timepoints. Any timezone information is ignored and the type is treated as a naive - object. + object in the local timezone. The ``fold`` attribute selects the earlier + (``0``) or later (``1``) occurrence of an ambiguous local time. + Nonexistent local times when clocks move forward follow Python's + ``datetime.timestamp()`` behavior and cannot round-trip unchanged. - ``datetime.timedelta`` → ``std::chrono::duration`` Time delta are converted into durations with microsecond precision. diff --git a/include/pybind11/chrono.h b/include/pybind11/chrono.h index 668e458e99..84c9aef3b8 100644 --- a/include/pybind11/chrono.h +++ b/include/pybind11/chrono.h @@ -16,7 +16,6 @@ #include #include #include -#include PYBIND11_NAMESPACE_BEGIN(PYBIND11_NAMESPACE) PYBIND11_NAMESPACE_BEGIN(detail) @@ -101,22 +100,6 @@ class duration_caster { PYBIND11_TYPE_CASTER(type, const_name("datetime.timedelta")); }; -inline std::tm *localtime_thread_safe(const std::time_t *time, std::tm *buf) { -#if (defined(__STDC_LIB_EXT1__) && defined(__STDC_WANT_LIB_EXT1__)) || defined(_MSC_VER) - if (localtime_s(buf, time)) - return nullptr; - return buf; -#else - static std::mutex mtx; - std::lock_guard lock(mtx); - std::tm *tm_ptr = std::localtime(time); - if (tm_ptr != nullptr) { - *buf = *tm_ptr; - } - return tm_ptr; -#endif -} - // This is for casting times on the system clock into datetime.datetime instances template class type_caster> { @@ -134,8 +117,9 @@ class type_caster> return false; } - std::tm cal; + std::tm cal{}; microseconds msecs; + int fold = 0; if (PyDateTime_Check(src.ptr())) { cal.tm_sec = PyDateTime_DATE_GET_SECOND(src.ptr()); @@ -146,6 +130,7 @@ class type_caster> cal.tm_year = PyDateTime_GET_YEAR(src.ptr()) - 1900; cal.tm_isdst = -1; msecs = microseconds(PyDateTime_DATE_GET_MICROSECOND(src.ptr())); + fold = PyDateTime_DATE_GET_FOLD(src.ptr()); } else if (PyDate_Check(src.ptr())) { cal.tm_sec = 0; cal.tm_min = 0; @@ -164,11 +149,41 @@ class type_caster> cal.tm_year = 70; // earliest available date for Python's datetime cal.tm_isdst = -1; msecs = microseconds(PyDateTime_TIME_GET_MICROSECOND(src.ptr())); + fold = PyDateTime_TIME_GET_FOLD(src.ptr()); } else { return false; } - value = time_point_cast(system_clock::from_time_t(std::mktime(&cal)) + msecs); +#if defined(_WIN32) + // Python's fold detection probes up to a day before the timestamp. Windows + // cannot represent those probes during the first day after the epoch, and + // datetime.fromtimestamp() also skips fold detection there. + if (cal.tm_year == 70 && cal.tm_mon == 0 && cal.tm_mday <= 2) { + std::tm cal_copy = cal; + const std::time_t tt = std::mktime(&cal_copy); + if (tt >= 0 && tt <= 86400) { + value = time_point_cast(system_clock::from_time_t(tt) + msecs); + return true; + } + } +#endif + + // Use Python's local-time disambiguation, preserving fold while ignoring + // tzinfo and datetime subclass overrides. Keep microseconds separate to + // avoid losing precision through timestamp()'s floating-point result. + auto dt = reinterpret_steal(PyDateTime_FromDateAndTimeAndFold(cal.tm_year + 1900, + cal.tm_mon + 1, + cal.tm_mday, + cal.tm_hour, + cal.tm_min, + cal.tm_sec, + 0, + fold)); + if (!dt) { + throw error_already_set(); + } + const auto tt = int_(dt.attr("timestamp")()).cast(); + value = time_point_cast(system_clock::from_time_t(tt) + msecs); return true; } @@ -196,18 +211,20 @@ class type_caster> std::time_t tt = system_clock::to_time_t(time_point_cast(src - us)); - std::tm localtime; - std::tm *localtime_ptr = localtime_thread_safe(&tt, &localtime); - if (!localtime_ptr) { - throw cast_error("Unable to represent system_clock in local time"); + // Python determines the local calendar fields and fold from whole seconds. + auto args = make_tuple(tt); + auto dt = reinterpret_steal(PyDateTime_FromTimestamp(args.ptr())); + if (!dt) { + throw error_already_set(); } - return PyDateTime_FromDateAndTime(localtime.tm_year + 1900, - localtime.tm_mon + 1, - localtime.tm_mday, - localtime.tm_hour, - localtime.tm_min, - localtime.tm_sec, - us.count()); + return PyDateTime_FromDateAndTimeAndFold(PyDateTime_GET_YEAR(dt.ptr()), + PyDateTime_GET_MONTH(dt.ptr()), + PyDateTime_GET_DAY(dt.ptr()), + PyDateTime_DATE_GET_HOUR(dt.ptr()), + PyDateTime_DATE_GET_MINUTE(dt.ptr()), + PyDateTime_DATE_GET_SECOND(dt.ptr()), + us.count(), + PyDateTime_DATE_GET_FOLD(dt.ptr())); } PYBIND11_TYPE_CASTER(type, const_name("datetime.datetime")); }; diff --git a/tests/test_chrono.cpp b/tests/test_chrono.cpp index 8be0ffd183..efaa9752e4 100644 --- a/tests/test_chrono.cpp +++ b/tests/test_chrono.cpp @@ -44,6 +44,15 @@ TEST_SUBMODULE(chrono, m) { // Round trip the passed in system clock time m.def("test_chrono2", [](system_time t) { return t; }); + // Check each direction independently, without an ambiguous local datetime + // hiding which instant the system-clock caster selected. + using timestamp_us + = std::chrono::time_point; + m.def("test_chrono_system_clock_as_us", + [](timestamp_us t) { return t.time_since_epoch().count(); }); + m.def("test_chrono_system_clock_from_us", + [](int64_t us) { return timestamp_us(std::chrono::microseconds(us)); }); + // test_chrono_duration_roundtrip // Round trip the passed in duration m.def("test_chrono3", [](std::chrono::system_clock::duration d) { return d; }); diff --git a/tests/test_chrono.py b/tests/test_chrono.py index ed889fbd6b..db6416fc5f 100644 --- a/tests/test_chrono.py +++ b/tests/test_chrono.py @@ -1,10 +1,12 @@ from __future__ import annotations import datetime +import sys +import time import pytest -import env # noqa: F401 +import env from pybind11_tests import chrono as m @@ -43,6 +45,14 @@ def test_chrono_system_clock_roundtrip(): assert diff == datetime.timedelta(0) +@pytest.mark.parametrize("day", [1, 2]) +def test_chrono_system_clock_roundtrip_epoch(day): + # Python's fold-aware naive timestamp implementation probes the previous + # day, which Windows cannot represent before the epoch. + value = datetime.datetime(1970, 1, day, 12) + assert m.test_chrono2(value) == value + + def test_chrono_system_clock_roundtrip_date(): date1 = datetime.date.today() @@ -72,6 +82,198 @@ def test_chrono_system_clock_roundtrip_date(): assert time2.microsecond == 0 +@pytest.fixture +def local_timezone(monkeypatch): + if not hasattr(time, "tzset"): + pytest.skip("Changing the local timezone requires time.tzset") + if env.GRAALPY and sys.graalpy_version_info < (25, 1): + pytest.skip("GraalPy before 25.1 does not update its timezone after tzset") + try: + with monkeypatch.context() as patch: + + def set_timezone(tz): + patch.setenv("TZ", tz) + time.tzset() + + yield set_timezone + finally: + time.tzset() + + +def epoch_us(utc): + delta = utc - datetime.datetime(1970, 1, 1) + return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds + + +# POSIX rules avoid a dependency on the host's IANA timezone data. GraalPy's +# Java timezone parser requires named zones instead of POSIX rule strings. +PACIFIC_TZ = "America/Los_Angeles" if env.GRAALPY else "PST8PDT,M3.2.0/2,M11.1.0/2" +HALF_HOUR_TZ = ( + "Australia/Lord_Howe" if env.GRAALPY else "XST-10:30XDT-11,M10.1.0/2,M4.1.0/2" +) + + +DST_CASES = [ + pytest.param( + PACIFIC_TZ, + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=0), + datetime.timedelta(hours=-7), + 0, + id="fall-first", + ), + pytest.param( + PACIFIC_TZ, + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=1), + datetime.timedelta(hours=-8), + 0, + id="fall-second", + ), + pytest.param( + PACIFIC_TZ, + datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=0), + datetime.timedelta(hours=-8), + 1, + id="spring-gap-first", + ), + pytest.param( + PACIFIC_TZ, + datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=1), + datetime.timedelta(hours=-7), + -1, + id="spring-gap-second", + ), + pytest.param( + HALF_HOUR_TZ, + datetime.datetime(2026, 4, 5, 1, 45, 42, 123456, fold=0), + datetime.timedelta(hours=11), + 0, + id="half-hour-first", + ), + pytest.param( + HALF_HOUR_TZ, + datetime.datetime(2026, 4, 5, 1, 45, 42, 123456, fold=1), + datetime.timedelta(hours=10, minutes=30), + 0, + id="half-hour-second", + ), +] + + +@pytest.mark.parametrize(("tz", "local", "offset", "_gap_hours"), DST_CASES) +def test_chrono_system_clock_load_dst(tz, local, offset, _gap_hours, local_timezone): + local_timezone(tz) + assert m.test_chrono_system_clock_as_us(local) == epoch_us(local - offset) + + +@pytest.mark.parametrize(("tz", "local", "offset", "gap_hours"), DST_CASES) +def test_chrono_system_clock_cast_dst(tz, local, offset, gap_hours, local_timezone): + local_timezone(tz) + value = epoch_us(local - offset) + # Use known wall times rather than Python's conversion as the oracle, so + # an unsupported TZ cannot make both conversions silently agree in UTC. + expected = ( + (local + datetime.timedelta(hours=gap_hours)).replace(fold=0) + if gap_hours + else local + ) + result = m.test_chrono_system_clock_from_us(value) + assert result == expected + # datetime equality ignores fold when comparing naive values. + assert result.fold == expected.fold + assert result.tzinfo is None + + +@pytest.mark.parametrize("fold", [0, 1]) +def test_chrono_system_clock_non_dst_fold(fold, local_timezone): + local_timezone("Europe/Kyiv") + first = epoch_us(datetime.datetime(1990, 6, 30, 21, 30)) + second = first + 3600_000_000 + # Both sides of this political offset change are daylight time. Skip if + # the system lacks the corresponding IANA timezone data. + for value in (first, second): + parts = time.localtime(value // 1_000_000) + if parts[:6] != (1990, 7, 1, 1, 30, 0) or parts.tm_isdst != 1: + pytest.skip("Europe/Kyiv's 1990 offset change is unavailable") + local = datetime.datetime(1990, 7, 1, 1, 30, fold=fold) + value = first if fold == 0 else second + assert m.test_chrono_system_clock_as_us(local) == value + result = m.test_chrono_system_clock_from_us(value) + assert result == local + assert result.fold == fold + + +@pytest.mark.parametrize( + "value", + [ + -1_000_001, + -1, + 0, + 1, + 1_000_001, + epoch_us(datetime.datetime(1900, 1, 1, 0, 0, 0, 123456)), + epoch_us(datetime.datetime(2200, 1, 1)), + epoch_us(datetime.datetime(2250, 1, 1, 0, 0, 0, 123456)), + ], +) +def test_chrono_system_clock_microseconds(value, local_timezone): + local_timezone("UTC0") + try: + datetime.datetime.fromtimestamp(value // 1_000_000) + except (OverflowError, OSError): + pytest.skip("Timestamp is outside the platform's supported range") + expected = datetime.datetime(1970, 1, 1) + datetime.timedelta(microseconds=value) + assert m.test_chrono_system_clock_as_us(expected) == value + assert m.test_chrono_system_clock_from_us(value) == expected + + +class DatetimeWithOverriddenTimestamp(datetime.datetime): + def timestamp(self): + raise AssertionError("The system-clock caster should read datetime fields") + + +@pytest.mark.parametrize( + ("source", "expected"), + [ + (datetime.date(2026, 11, 1), datetime.datetime(2026, 11, 1)), + ( + datetime.time(12, 34, 56, 123456), + datetime.datetime(1970, 1, 1, 12, 34, 56, 123456), + ), + ( + datetime.time( + 12, + 34, + 56, + 123456, + tzinfo=datetime.timezone(datetime.timedelta(hours=12)), + fold=1, + ), + datetime.datetime(1970, 1, 1, 12, 34, 56, 123456), + ), + ( + datetime.datetime( + 2026, + 11, + 1, + 1, + 30, + 42, + 123456, + tzinfo=datetime.timezone(datetime.timedelta(hours=12)), + ), + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456), + ), + ( + DatetimeWithOverriddenTimestamp(2026, 11, 1, 1, 30, 42, 123456), + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456), + ), + ], +) +def test_chrono_system_clock_input_fields(source, expected, local_timezone): + local_timezone("UTC0") + assert m.test_chrono_system_clock_as_us(source) == epoch_us(expected) + + SKIP_TZ_ENV_ON_WIN = pytest.mark.skipif( "env.WIN", reason="TZ environment variable only supported on POSIX" ) @@ -99,9 +301,10 @@ def test_chrono_system_clock_roundtrip_date(): pytest.param("America/New_York", marks=SKIP_TZ_ENV_ON_WIN), ], ) -def test_chrono_system_clock_roundtrip_time(time1, tz, monkeypatch): +def test_chrono_system_clock_roundtrip_time(time1, tz, request): if tz is not None: - monkeypatch.setenv("TZ", f"/usr/share/zoneinfo/{tz}") + local_tz = tz if env.GRAALPY else f"/usr/share/zoneinfo/{tz}" + request.getfixturevalue("local_timezone")(local_tz) # Roundtrip the time datetime2 = m.test_chrono2(time1)