From de8891c34a076f1611944037f22aca0cbf696414 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 10 Oct 2026 13:14:27 -0700 Subject: [PATCH 1/5] fix: preserve ambiguous local times in chrono conversions --- docs/advanced/cast/chrono.rst | 9 +- include/pybind11/chrono.h | 77 ++++++++------ tests/test_chrono.cpp | 9 ++ tests/test_chrono.py | 187 +++++++++++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 34 deletions(-) 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..3e8906cda8 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. + object 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); + object 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..9dcbe1074a 100644 --- a/tests/test_chrono.py +++ b/tests/test_chrono.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import time import pytest @@ -43,6 +44,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 +81,180 @@ 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") + 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 make the core cases independent of the host's IANA timezone data. +DST_CASES = [ + pytest.param( + "PST8PDT,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=0), + datetime.timedelta(hours=-7), + id="fall-first", + ), + pytest.param( + "PST8PDT,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=1), + datetime.timedelta(hours=-8), + id="fall-second", + ), + pytest.param( + "PST8PDT,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=0), + datetime.timedelta(hours=-8), + id="spring-gap-first", + ), + pytest.param( + "PST8PDT,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=1), + datetime.timedelta(hours=-7), + id="spring-gap-second", + ), + pytest.param( + "XST-10XDT-10:30,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 11, 1, 1, 45, 42, 123456, fold=0), + datetime.timedelta(hours=10, minutes=30), + id="half-hour-first", + ), + pytest.param( + "XST-10XDT-10:30,M3.2.0/2,M11.1.0/2", + datetime.datetime(2026, 11, 1, 1, 45, 42, 123456, fold=1), + datetime.timedelta(hours=10), + id="half-hour-second", + ), +] + + +@pytest.mark.parametrize(("tz", "local", "offset"), DST_CASES) +def test_chrono_system_clock_load_dst(tz, local, offset, local_timezone): + local_timezone(tz) + assert m.test_chrono_system_clock_as_us(local) == epoch_us(local - offset) + + +@pytest.mark.parametrize(("tz", "local", "offset"), DST_CASES) +def test_chrono_system_clock_cast_dst(tz, local, offset, local_timezone): + local_timezone(tz) + value = epoch_us(local - offset) + seconds, microseconds = divmod(value, 1_000_000) + expected = datetime.datetime.fromtimestamp(seconds).replace( + microsecond=microseconds + ) + 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 +282,9 @@ 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}") + request.getfixturevalue("local_timezone")(f"/usr/share/zoneinfo/{tz}") # Roundtrip the time datetime2 = m.test_chrono2(time1) From 9e309163df7fa40dac63f7246fd6eebd2ddf4fc3 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 10 Oct 2026 13:40:34 -0700 Subject: [PATCH 2/5] fix: use GraalPy-compatible time zones in chrono tests --- tests/test_chrono.py | 54 ++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/tests/test_chrono.py b/tests/test_chrono.py index 9dcbe1074a..490ea7af1c 100644 --- a/tests/test_chrono.py +++ b/tests/test_chrono.py @@ -5,7 +5,7 @@ import pytest -import env # noqa: F401 +import env from pybind11_tests import chrono as m @@ -102,60 +102,76 @@ def epoch_us(utc): return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds -# POSIX rules make the core cases independent of the host's IANA timezone data. +# 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( - "PST8PDT,M3.2.0/2,M11.1.0/2", + PACIFIC_TZ, datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=0), datetime.timedelta(hours=-7), + 0, id="fall-first", ), pytest.param( - "PST8PDT,M3.2.0/2,M11.1.0/2", + PACIFIC_TZ, datetime.datetime(2026, 11, 1, 1, 30, 42, 123456, fold=1), datetime.timedelta(hours=-8), + 0, id="fall-second", ), pytest.param( - "PST8PDT,M3.2.0/2,M11.1.0/2", + PACIFIC_TZ, datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=0), datetime.timedelta(hours=-8), + 1, id="spring-gap-first", ), pytest.param( - "PST8PDT,M3.2.0/2,M11.1.0/2", + PACIFIC_TZ, datetime.datetime(2026, 3, 8, 2, 30, 42, 654321, fold=1), datetime.timedelta(hours=-7), + -1, id="spring-gap-second", ), pytest.param( - "XST-10XDT-10:30,M3.2.0/2,M11.1.0/2", - datetime.datetime(2026, 11, 1, 1, 45, 42, 123456, fold=0), - datetime.timedelta(hours=10, minutes=30), + 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( - "XST-10XDT-10:30,M3.2.0/2,M11.1.0/2", - datetime.datetime(2026, 11, 1, 1, 45, 42, 123456, fold=1), - datetime.timedelta(hours=10), + 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"), DST_CASES) -def test_chrono_system_clock_load_dst(tz, local, offset, local_timezone): +@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"), DST_CASES) -def test_chrono_system_clock_cast_dst(tz, local, offset, local_timezone): +@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) - seconds, microseconds = divmod(value, 1_000_000) - expected = datetime.datetime.fromtimestamp(seconds).replace( - microsecond=microseconds + # 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 From 5381d2ea8c77f55d51f42e2b33a96b4b76a10627 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 10 Oct 2026 13:57:57 -0700 Subject: [PATCH 3/5] fix: satisfy clang-tidy in chrono conversions --- include/pybind11/chrono.h | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/include/pybind11/chrono.h b/include/pybind11/chrono.h index 3e8906cda8..84c9aef3b8 100644 --- a/include/pybind11/chrono.h +++ b/include/pybind11/chrono.h @@ -171,14 +171,14 @@ class type_caster> // 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. - object 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)); + 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(); } @@ -213,7 +213,7 @@ class type_caster> // Python determines the local calendar fields and fold from whole seconds. auto args = make_tuple(tt); - object dt = reinterpret_steal(PyDateTime_FromTimestamp(args.ptr())); + auto dt = reinterpret_steal(PyDateTime_FromTimestamp(args.ptr())); if (!dt) { throw error_already_set(); } From 53d2f9be141e99b1808fad8c578854666b71cf1c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 10 Oct 2026 21:59:34 -0700 Subject: [PATCH 4/5] test: isolate timezone changes on older GraalPy --- tests/test_chrono.py | 62 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/tests/test_chrono.py b/tests/test_chrono.py index 490ea7af1c..69e40ae771 100644 --- a/tests/test_chrono.py +++ b/tests/test_chrono.py @@ -1,7 +1,12 @@ from __future__ import annotations import datetime +import os +import subprocess +import sys +import tempfile import time +import xml.etree.ElementTree as ET import pytest @@ -82,15 +87,51 @@ def test_chrono_system_clock_roundtrip_date(): @pytest.fixture -def local_timezone(monkeypatch): +def local_timezone(monkeypatch, request): + """Select a timezone, returning True if the test ran in a subprocess.""" if not hasattr(time, "tzset"): pytest.skip("Changing the local timezone requires time.tzset") try: with monkeypatch.context() as patch: def set_timezone(tz): + # GraalPy before 25.1 keeps its startup timezone after tzset(). + # Run the same test in a fresh interpreter to retain coverage. + if env.GRAALPY and sys.graalpy_version_info < (25, 1): + if os.environ.get("PYBIND11_TEST_CHRONO_IN_SUBPROCESS"): + assert os.environ.get("TZ") == tz + else: + child_env = os.environ.copy() + child_env["TZ"] = tz + child_env["PYBIND11_TEST_CHRONO_IN_SUBPROCESS"] = "1" + child_env["PYTHONPATH"] = os.pathsep.join( + os.path.abspath(path) for path in sys.path + ) + child_env.pop("PYTEST_ADDOPTS", None) + with tempfile.TemporaryDirectory() as directory: + report_path = os.path.join(directory, "pytest.xml") + subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-o", + "addopts=", + "--junitxml", + report_path, + request.node.nodeid, + ], + cwd=request.config.rootpath, + env=child_env, + check=True, + ) + for skipped in ET.parse(report_path).iter("skipped"): + pytest.skip(skipped.get("message")) + return True patch.setenv("TZ", tz) time.tzset() + return False yield set_timezone finally: @@ -158,13 +199,15 @@ def epoch_us(utc): @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) + if local_timezone(tz): + return 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) + if local_timezone(tz): + return 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. @@ -182,7 +225,8 @@ def test_chrono_system_clock_cast_dst(tz, local, offset, gap_hours, local_timezo @pytest.mark.parametrize("fold", [0, 1]) def test_chrono_system_clock_non_dst_fold(fold, local_timezone): - local_timezone("Europe/Kyiv") + if local_timezone("Europe/Kyiv"): + return 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 @@ -213,7 +257,8 @@ def test_chrono_system_clock_non_dst_fold(fold, local_timezone): ], ) def test_chrono_system_clock_microseconds(value, local_timezone): - local_timezone("UTC0") + if local_timezone("UTC0"): + return try: datetime.datetime.fromtimestamp(value // 1_000_000) except (OverflowError, OSError): @@ -267,7 +312,8 @@ def timestamp(self): ], ) def test_chrono_system_clock_input_fields(source, expected, local_timezone): - local_timezone("UTC0") + if local_timezone("UTC0"): + return assert m.test_chrono_system_clock_as_us(source) == epoch_us(expected) @@ -300,7 +346,9 @@ def test_chrono_system_clock_input_fields(source, expected, local_timezone): ) def test_chrono_system_clock_roundtrip_time(time1, tz, request): if tz is not None: - request.getfixturevalue("local_timezone")(f"/usr/share/zoneinfo/{tz}") + local_tz = tz if env.GRAALPY else f"/usr/share/zoneinfo/{tz}" + if request.getfixturevalue("local_timezone")(local_tz): + return # Roundtrip the time datetime2 = m.test_chrono2(time1) From 5d2b952d6fe80801ae789ce14f804c2a7713436a Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 10 Oct 2026 23:08:47 -0700 Subject: [PATCH 5/5] test: skip timezone changes on older GraalPy --- tests/test_chrono.py | 62 +++++++------------------------------------- 1 file changed, 9 insertions(+), 53 deletions(-) diff --git a/tests/test_chrono.py b/tests/test_chrono.py index 69e40ae771..db6416fc5f 100644 --- a/tests/test_chrono.py +++ b/tests/test_chrono.py @@ -1,12 +1,8 @@ from __future__ import annotations import datetime -import os -import subprocess import sys -import tempfile import time -import xml.etree.ElementTree as ET import pytest @@ -87,51 +83,17 @@ def test_chrono_system_clock_roundtrip_date(): @pytest.fixture -def local_timezone(monkeypatch, request): - """Select a timezone, returning True if the test ran in a subprocess.""" +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): - # GraalPy before 25.1 keeps its startup timezone after tzset(). - # Run the same test in a fresh interpreter to retain coverage. - if env.GRAALPY and sys.graalpy_version_info < (25, 1): - if os.environ.get("PYBIND11_TEST_CHRONO_IN_SUBPROCESS"): - assert os.environ.get("TZ") == tz - else: - child_env = os.environ.copy() - child_env["TZ"] = tz - child_env["PYBIND11_TEST_CHRONO_IN_SUBPROCESS"] = "1" - child_env["PYTHONPATH"] = os.pathsep.join( - os.path.abspath(path) for path in sys.path - ) - child_env.pop("PYTEST_ADDOPTS", None) - with tempfile.TemporaryDirectory() as directory: - report_path = os.path.join(directory, "pytest.xml") - subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "-q", - "-o", - "addopts=", - "--junitxml", - report_path, - request.node.nodeid, - ], - cwd=request.config.rootpath, - env=child_env, - check=True, - ) - for skipped in ET.parse(report_path).iter("skipped"): - pytest.skip(skipped.get("message")) - return True patch.setenv("TZ", tz) time.tzset() - return False yield set_timezone finally: @@ -199,15 +161,13 @@ def epoch_us(utc): @pytest.mark.parametrize(("tz", "local", "offset", "_gap_hours"), DST_CASES) def test_chrono_system_clock_load_dst(tz, local, offset, _gap_hours, local_timezone): - if local_timezone(tz): - return + 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): - if local_timezone(tz): - return + 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. @@ -225,8 +185,7 @@ def test_chrono_system_clock_cast_dst(tz, local, offset, gap_hours, local_timezo @pytest.mark.parametrize("fold", [0, 1]) def test_chrono_system_clock_non_dst_fold(fold, local_timezone): - if local_timezone("Europe/Kyiv"): - return + 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 @@ -257,8 +216,7 @@ def test_chrono_system_clock_non_dst_fold(fold, local_timezone): ], ) def test_chrono_system_clock_microseconds(value, local_timezone): - if local_timezone("UTC0"): - return + local_timezone("UTC0") try: datetime.datetime.fromtimestamp(value // 1_000_000) except (OverflowError, OSError): @@ -312,8 +270,7 @@ def timestamp(self): ], ) def test_chrono_system_clock_input_fields(source, expected, local_timezone): - if local_timezone("UTC0"): - return + local_timezone("UTC0") assert m.test_chrono_system_clock_as_us(source) == epoch_us(expected) @@ -347,8 +304,7 @@ def test_chrono_system_clock_input_fields(source, expected, local_timezone): def test_chrono_system_clock_roundtrip_time(time1, tz, request): if tz is not None: local_tz = tz if env.GRAALPY else f"/usr/share/zoneinfo/{tz}" - if request.getfixturevalue("local_timezone")(local_tz): - return + request.getfixturevalue("local_timezone")(local_tz) # Roundtrip the time datetime2 = m.test_chrono2(time1)