From 797b221fc96c204a9d79ee134d316d30d6c99128 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 17 Apr 2026 19:08:42 +0800 Subject: [PATCH 1/4] gh-148658: Fix DST timezone name for negative timestamps on Windows (https://github.com/python/cpython/issues/148658) Set tm_isdst to 0 for timezones that don't observe DST to ensure strftime('%Z') returns the standard time name. --- Python/pytime.c | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Python/pytime.c b/Python/pytime.c index 399ff59ad01ab6..e9528b402ed417 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -10,6 +10,7 @@ #endif #ifdef MS_WINDOWS # include // struct timeval +# include // time zone APIs #endif #if defined(__APPLE__) @@ -355,8 +356,24 @@ _PyTime_windows_filetime(time_t timer, struct tm *tm, int is_local) // `time.gmtime` and `time.localtime` will return `struct_time` containing this tm->tm_yday = _PyTime_calc_yday(&st_result); - /* DST flag: -1 (unknown) for local time on historical dates, 0 for UTC */ - tm->tm_isdst = is_local ? -1 : 0; + /* DST flag: -1 (unknown) for local time on historical dates, 0 for UTC. + * For timezones that don't observe DST, set tm_isdst to 0 to ensure + * strftime('%Z') returns the standard time name (gh-148658). */ + if (is_local) { + TIME_ZONE_INFORMATION tzi; + DWORD tz_result = GetTimeZoneInformation(&tzi); + if (tz_result != TIME_ZONE_ID_INVALID && + tzi.DaylightDate.wMonth == 0) { + /* Timezone does not observe DST */ + tm->tm_isdst = 0; + } + else { + tm->tm_isdst = -1; + } + } + else { + tm->tm_isdst = 0; + } return 0; } From 7398f51b3b3ca3c6bd8edc56a003ff3b8ebe1b6f Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 17 Apr 2026 19:42:23 +0800 Subject: [PATCH 2/4] gh-148658: Add news entry --- .../next/Windows/2026-04-17-19-42-04.gh-issue-148658.yhgT1z.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Windows/2026-04-17-19-42-04.gh-issue-148658.yhgT1z.rst diff --git a/Misc/NEWS.d/next/Windows/2026-04-17-19-42-04.gh-issue-148658.yhgT1z.rst b/Misc/NEWS.d/next/Windows/2026-04-17-19-42-04.gh-issue-148658.yhgT1z.rst new file mode 100644 index 00000000000000..f2b37ef0e1ec93 --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2026-04-17-19-42-04.gh-issue-148658.yhgT1z.rst @@ -0,0 +1 @@ +Fix DST timezone name for negative timestamps on Windows. From 1d0c65f058f65e47a123fbce773f60609cca620a Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 17 Apr 2026 20:55:28 +0800 Subject: [PATCH 3/4] gh-148658: Add test for negative timestamp DST handling --- Lib/test/datetimetester.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 5d5b8e415f3cd2..ea6203477c26fb 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -5661,6 +5661,15 @@ def test_astimezone_default_near_fold(self): s = t.astimezone() self.assertEqual(t.tzinfo, s.tzinfo) + @unittest.skipIf(sys.platform != "win32", "gh-148658 only affects Windows") + def test_astimezone_negative_timestamp_dst(self): + # gh-148658: astimezone() returned DST name for negative timestamps + # on Windows. Verify that the timezone name is consistent between + # negative and non-negative timestamps. + dt_neg1 = self.theclass.fromtimestamp(-1).astimezone() + dt_zero = self.theclass.fromtimestamp(0).astimezone() + self.assertEqual(dt_neg1.tzname(), dt_zero.tzname()) + def test_aware_subtract(self): cls = self.theclass From 9ab4d2369a225e83ca0158e06ca0aaa46eaaa1ea Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 17 Apr 2026 10:44:57 -0400 Subject: [PATCH 4/4] gh-148658: Fix DST detection for negative timestamps on Windows --- Lib/test/datetimetester.py | 34 ++++++++++++++++++++++++++++------ Python/pytime.c | 27 +++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index ea6203477c26fb..65c034c054a17a 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -5663,12 +5663,34 @@ def test_astimezone_default_near_fold(self): @unittest.skipIf(sys.platform != "win32", "gh-148658 only affects Windows") def test_astimezone_negative_timestamp_dst(self): - # gh-148658: astimezone() returned DST name for negative timestamps - # on Windows. Verify that the timezone name is consistent between - # negative and non-negative timestamps. - dt_neg1 = self.theclass.fromtimestamp(-1).astimezone() - dt_zero = self.theclass.fromtimestamp(0).astimezone() - self.assertEqual(dt_neg1.tzname(), dt_zero.tzname()) + # gh-148658: astimezone() returned incorrect DST name for negative + # timestamps on Windows. Verify C layer returns correct tm_zone + # by checking astimezone() produces correct timezone names. + import time + winter_ts = -1 # 1969-12-31 + summer_ts = -15897600 # 1969-07-01 + + # Get expected timezone names from time.localtime + winter_local = time.localtime(winter_ts) + summer_local = time.localtime(summer_ts) + + dt_winter = self.theclass.fromtimestamp(winter_ts).astimezone() + dt_summer = self.theclass.fromtimestamp(summer_ts).astimezone() + + # Verify astimezone() returns correct timezone names + self.assertEqual(dt_winter.tzname(), winter_local.tm_zone) + self.assertEqual(dt_summer.tzname(), summer_local.tm_zone) + + # For DST timezones, winter and summer should have different names + # For non-DST timezones, names should be the same + if winter_local.tm_zone == summer_local.tm_zone: + # Non-DST timezone: both should be standard time + self.assertEqual(winter_local.tm_isdst, 0) + self.assertEqual(summer_local.tm_isdst, 0) + else: + # DST timezone: one should be standard, one DST + self.assertEqual(winter_local.tm_isdst, 0) + self.assertEqual(summer_local.tm_isdst, 1) def test_aware_subtract(self): cls = self.theclass diff --git a/Python/pytime.c b/Python/pytime.c index e9528b402ed417..2bd20ac38f11d3 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -357,18 +357,37 @@ _PyTime_windows_filetime(time_t timer, struct tm *tm, int is_local) tm->tm_yday = _PyTime_calc_yday(&st_result); /* DST flag: -1 (unknown) for local time on historical dates, 0 for UTC. - * For timezones that don't observe DST, set tm_isdst to 0 to ensure - * strftime('%Z') returns the standard time name (gh-148658). */ + * Detect DST by comparing standard time with local time (gh-148658). */ if (is_local) { TIME_ZONE_INFORMATION tzi; DWORD tz_result = GetTimeZoneInformation(&tzi); - if (tz_result != TIME_ZONE_ID_INVALID && + if (tz_result == TIME_ZONE_ID_INVALID || tzi.DaylightDate.wMonth == 0) { /* Timezone does not observe DST */ tm->tm_isdst = 0; } else { - tm->tm_isdst = -1; + /* Calculate standard time from UTC and compare with local time. */ + ULONGLONG utc_ticks = ((ULONGLONG)timer + SECS_BETWEEN_EPOCHS) * + HUNDRED_NS_PER_SEC; + LONGLONG standard_offset_ticks = (LONGLONG)tzi.Bias * 60LL * + HUNDRED_NS_PER_SEC; + ULONGLONG standard_ticks = utc_ticks - standard_offset_ticks; + FILETIME ft_standard; + ft_standard.dwLowDateTime = (DWORD)(standard_ticks); + ft_standard.dwHighDateTime = (DWORD)(standard_ticks >> 32); + SYSTEMTIME st_standard; + if (!FileTimeToSystemTime(&ft_standard, &st_standard)) { + PyErr_SetFromWindowsErr(0); + return -1; + } + if (st_result.wHour == st_standard.wHour && + st_result.wMinute == st_standard.wMinute) { + tm->tm_isdst = 0; + } + else { + tm->tm_isdst = 1; + } } } else {