From 59cb85df828fe2f71ffe3d4913d41dcf61c17b19 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 4 Nov 2026 12:07:42 -0700 Subject: [PATCH 1/6] gh-161194: Use libc timezone variables for time module constants Previously the libc path in init_timezone() was gated on HAVE_DECL_TZNAME, which configure only defines when struct tm lacks tm_zone. On Linux and macOS this meant timezone, altzone, daylight and tzname were guessed by sampling localtime() near January 1 and July 1 of the current year. That gives stale values when a zone's rules change mid-year (e.g. America/Vancouver moving to permanent MST on 2026-11-01) and wrong standard/DST names for negative DST zones. Add a configure check, HAVE_TIMEZONE_GLOBALS, that verifies timezone, daylight and tzname are usable as variables, and use libc's values whenever it is defined. FreeBSD, where timezone is a function, keeps the sampling fallback. When libc has no altzone, use timezone - 3600 only if daylight is set, otherwise timezone. --- Lib/test/test_time.py | 3 +- .../2026-11-04-12-00-00.tz-libc-globals.rst | 7 +++ Modules/timemodule.c | 14 +++--- PC/pyconfig.h | 5 +-- configure | 43 +++++++++++++++++++ configure.ac | 16 +++++++ pyconfig.h.in | 3 ++ 7 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index dac4dd9060ccdd1..88d88c4cae8e4f1 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -509,7 +509,7 @@ def test_tzset(self): try: # Make sure we can switch to UTC time and results are correct # Note that unknown timezones default to UTC. - # Note that altzone is undefined in UTC, as there is no DST + # Note that altzone equals timezone in UTC, as there is no DST environ['TZ'] = eastern time.tzset() environ['TZ'] = utc @@ -519,6 +519,7 @@ def test_tzset(self): ) self.assertEqual(time.daylight, 0) self.assertEqual(time.timezone, 0) + self.assertEqual(time.altzone, 0) self.assertEqual(time.localtime(xmas2002).tm_isdst, 0) # Make sure we can switch to US/Eastern diff --git a/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst b/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst new file mode 100644 index 000000000000000..cd2fe1530b8fae1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst @@ -0,0 +1,7 @@ +On platforms where libc provides the ``timezone``, ``daylight`` and +``tzname`` variables, :data:`time.timezone`, :data:`time.daylight` and +:data:`time.tzname` are now taken from libc instead of being guessed from +samples of the local time in January and July. This fixes stale values for +zones whose rules change during the year, such as ``America/Vancouver`` in +2026. :data:`time.altzone` now equals :data:`time.timezone` for zones without +DST when libc does not provide ``altzone``. diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 064ab2cf2364f1a..8529d4cf4a12206 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1854,7 +1854,7 @@ time_get_clock_info_impl(PyObject *module, const char *name) return NULL; } -#ifndef HAVE_DECL_TZNAME +#ifndef HAVE_TIMEZONE_GLOBALS static void get_zone(char *zone, int n, struct tm *p) { @@ -1875,7 +1875,7 @@ get_gmtoff(time_t t, struct tm *p) return timegm(p) - t; #endif } -#endif // !HAVE_DECL_TZNAME +#endif // !HAVE_TIMEZONE_GLOBALS static int init_timezone(PyObject *m) @@ -1904,7 +1904,7 @@ init_timezone(PyObject *m) And I'm lazy and hate C so nyer. */ -#ifdef HAVE_DECL_TZNAME +#ifdef HAVE_TIMEZONE_GLOBALS PyObject *otz0, *otz1; #if !defined(MS_WINDOWS) || defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM) tzset(); @@ -1913,7 +1913,9 @@ init_timezone(PyObject *m) #ifdef HAVE_ALTZONE ADD_INT("altzone", altzone); #else - ADD_INT("altzone", _Py_timezone-3600); + /* libc does not provide the DST offset. Assume a one hour shift when + the zone has DST, otherwise use the standard offset. */ + ADD_INT("altzone", _Py_daylight ? _Py_timezone - 3600 : _Py_timezone); #endif ADD_INT("daylight", _Py_daylight); #ifdef MS_WINDOWS @@ -1942,7 +1944,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", Py_BuildValue("(NN)", otz0, otz1)) < 0) { return -1; } -#else // !HAVE_DECL_TZNAME +#else // !HAVE_TIMEZONE_GLOBALS static const time_t YEAR = (365 * 24 + 6) * 3600; time_t t; struct tm p; @@ -1987,7 +1989,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", tzname_obj) < 0) { return -1; } -#endif // !HAVE_DECL_TZNAME +#endif // !HAVE_TIMEZONE_GLOBALS #undef ADD_INT if (PyErr_Occurred()) { diff --git a/PC/pyconfig.h b/PC/pyconfig.h index 4216847e2eaec41..8ede371ba488ea1 100644 --- a/PC/pyconfig.h +++ b/PC/pyconfig.h @@ -429,9 +429,8 @@ Py_NO_ENABLE_SHARED to find out. Also support MS_NO_COREDLL for b/w compat */ /* Define to 1 if you have the header file. */ #define HAVE_DIRECT_H 1 -/* Define to 1 if you have the declaration of `tzname', and to 0 if you don't. - */ -#define HAVE_DECL_TZNAME 1 +/* Define if time.h declares the timezone, daylight and tzname variables. */ +#define HAVE_TIMEZONE_GLOBALS 1 /* Define if you have dirent.h. */ /* #define DIRENT 1 */ diff --git a/configure b/configure index f85f487e0249d67..a164f36bbb8dc01 100755 --- a/configure +++ b/configure @@ -27151,6 +27151,49 @@ printf "%s\n" "#define HAVE_ALTZONE 1" >>confdefs.h fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for time.h that defines timezone, daylight and tzname variables" >&5 +printf %s "checking for time.h that defines timezone, daylight and tzname variables... " >&6; } +if test ${ac_cv_header_time_tz_globals+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) + cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ +#include +int +main (void) +{ + + long tz = timezone; + int dl = daylight; + char *name = tzname[0]; + return (int)tz + dl + (name != 0); + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_link "$LINENO" +then : + ac_cv_header_time_tz_globals=yes +else case e in #( + e) ac_cv_header_time_tz_globals=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam \ + conftest$ac_exeext conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_header_time_tz_globals" >&5 +printf "%s\n" "$ac_cv_header_time_tz_globals" >&6; } +if test $ac_cv_header_time_tz_globals = yes; then + +printf "%s\n" "#define HAVE_TIMEZONE_GLOBALS 1" >>confdefs.h + +fi + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for addrinfo" >&5 printf %s "checking for addrinfo... " >&6; } if test ${ac_cv_struct_addrinfo+y} diff --git a/configure.ac b/configure.ac index 4b8e6d59a5cf46d..a122fb24c0b7022 100644 --- a/configure.ac +++ b/configure.ac @@ -6545,6 +6545,22 @@ if test $ac_cv_header_time_altzone = yes; then [Define this if your time.h defines altzone.]) fi +AC_CACHE_CHECK([for time.h that defines timezone, daylight and tzname variables], + [ac_cv_header_time_tz_globals], [ + AC_LINK_IFELSE([AC_LANG_PROGRAM([[@%:@include ]], [[ + long tz = timezone; + int dl = daylight; + char *name = tzname[0]; + return (int)tz + dl + (name != 0); + ]])], + [ac_cv_header_time_tz_globals=yes], + [ac_cv_header_time_tz_globals=no]) + ]) +if test $ac_cv_header_time_tz_globals = yes; then + AC_DEFINE([HAVE_TIMEZONE_GLOBALS], [1], + [Define if time.h declares the timezone, daylight and tzname variables.]) +fi + AC_CACHE_CHECK([for addrinfo], [ac_cv_struct_addrinfo], AC_COMPILE_IFELSE([AC_LANG_PROGRAM([[@%:@include ]], [[struct addrinfo a]])], [ac_cv_struct_addrinfo=yes], diff --git a/pyconfig.h.in b/pyconfig.h.in index c575cbf0bf1c779..e7852d4d44fabf7 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1622,6 +1622,9 @@ /* Define to 1 if you have the 'times' function. */ #undef HAVE_TIMES +/* Define if time.h declares the timezone, daylight and tzname variables. */ +#undef HAVE_TIMEZONE_GLOBALS + /* Define to 1 if you have the 'tmpfile' function. */ #undef HAVE_TMPFILE From f1ed4a2158d6b10d66e9b8e3ac7f7b92ba359e94 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 4 Nov 2026 12:13:33 -0700 Subject: [PATCH 2/6] Fix NEWS file. --- ...als.rst => 2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename Misc/NEWS.d/next/Library/{2026-11-04-12-00-00.tz-libc-globals.rst => 2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst} (87%) diff --git a/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst b/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst similarity index 87% rename from Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst rename to Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst index cd2fe1530b8fae1..b498ad17519e568 100644 --- a/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.tz-libc-globals.rst +++ b/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst @@ -3,5 +3,5 @@ On platforms where libc provides the ``timezone``, ``daylight`` and :data:`time.tzname` are now taken from libc instead of being guessed from samples of the local time in January and July. This fixes stale values for zones whose rules change during the year, such as ``America/Vancouver`` in -2026. :data:`time.altzone` now equals :data:`time.timezone` for zones without -DST when libc does not provide ``altzone``. +2026. :data:`time.altzone` now equals :data:`time.timezone` for zones +without DST when libc does not provide ``altzone``. From afe9538517c88cf62b732cd276ff8ad480661ff3 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Wed, 4 Nov 2026 14:55:18 -0700 Subject: [PATCH 3/6] Use _Py_ prefix on new define. --- Modules/timemodule.c | 10 +++++----- PC/pyconfig.h | 2 +- configure | 2 +- configure.ac | 2 +- pyconfig.h.in | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 8529d4cf4a12206..66d2ede9214d704 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1854,7 +1854,7 @@ time_get_clock_info_impl(PyObject *module, const char *name) return NULL; } -#ifndef HAVE_TIMEZONE_GLOBALS +#ifndef _Py_HAVE_TIMEZONE_GLOBALS static void get_zone(char *zone, int n, struct tm *p) { @@ -1875,7 +1875,7 @@ get_gmtoff(time_t t, struct tm *p) return timegm(p) - t; #endif } -#endif // !HAVE_TIMEZONE_GLOBALS +#endif // !_Py_HAVE_TIMEZONE_GLOBALS static int init_timezone(PyObject *m) @@ -1904,7 +1904,7 @@ init_timezone(PyObject *m) And I'm lazy and hate C so nyer. */ -#ifdef HAVE_TIMEZONE_GLOBALS +#ifdef _Py_HAVE_TIMEZONE_GLOBALS PyObject *otz0, *otz1; #if !defined(MS_WINDOWS) || defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM) tzset(); @@ -1944,7 +1944,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", Py_BuildValue("(NN)", otz0, otz1)) < 0) { return -1; } -#else // !HAVE_TIMEZONE_GLOBALS +#else // !_Py_HAVE_TIMEZONE_GLOBALS static const time_t YEAR = (365 * 24 + 6) * 3600; time_t t; struct tm p; @@ -1989,7 +1989,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", tzname_obj) < 0) { return -1; } -#endif // !HAVE_TIMEZONE_GLOBALS +#endif // !_Py_HAVE_TIMEZONE_GLOBALS #undef ADD_INT if (PyErr_Occurred()) { diff --git a/PC/pyconfig.h b/PC/pyconfig.h index 8ede371ba488ea1..0ec7c8776127241 100644 --- a/PC/pyconfig.h +++ b/PC/pyconfig.h @@ -430,7 +430,7 @@ Py_NO_ENABLE_SHARED to find out. Also support MS_NO_COREDLL for b/w compat */ #define HAVE_DIRECT_H 1 /* Define if time.h declares the timezone, daylight and tzname variables. */ -#define HAVE_TIMEZONE_GLOBALS 1 +#define _Py_HAVE_TIMEZONE_GLOBALS 1 /* Define if you have dirent.h. */ /* #define DIRENT 1 */ diff --git a/configure b/configure index a164f36bbb8dc01..445ffadab31cdf0 100755 --- a/configure +++ b/configure @@ -27190,7 +27190,7 @@ fi printf "%s\n" "$ac_cv_header_time_tz_globals" >&6; } if test $ac_cv_header_time_tz_globals = yes; then -printf "%s\n" "#define HAVE_TIMEZONE_GLOBALS 1" >>confdefs.h +printf "%s\n" "#define _Py_HAVE_TIMEZONE_GLOBALS 1" >>confdefs.h fi diff --git a/configure.ac b/configure.ac index a122fb24c0b7022..7c62a5abb7477e9 100644 --- a/configure.ac +++ b/configure.ac @@ -6557,7 +6557,7 @@ AC_CACHE_CHECK([for time.h that defines timezone, daylight and tzname variables] [ac_cv_header_time_tz_globals=no]) ]) if test $ac_cv_header_time_tz_globals = yes; then - AC_DEFINE([HAVE_TIMEZONE_GLOBALS], [1], + AC_DEFINE([_Py_HAVE_TIMEZONE_GLOBALS], [1], [Define if time.h declares the timezone, daylight and tzname variables.]) fi diff --git a/pyconfig.h.in b/pyconfig.h.in index e7852d4d44fabf7..67ee81a7ff534af 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -1622,9 +1622,6 @@ /* Define to 1 if you have the 'times' function. */ #undef HAVE_TIMES -/* Define if time.h declares the timezone, daylight and tzname variables. */ -#undef HAVE_TIMEZONE_GLOBALS - /* Define to 1 if you have the 'tmpfile' function. */ #undef HAVE_TMPFILE @@ -2177,6 +2174,9 @@ /* Define if you have the 'tanpi' function. */ #undef _Py_HAVE_TANPI +/* Define if time.h declares the timezone, daylight and tzname variables. */ +#undef _Py_HAVE_TIMEZONE_GLOBALS + /* Thread stack size set by the linker (in bytes). */ #undef _Py_LINKER_THREAD_STACK_SIZE From dd84b44ae1f20bf4be2080b7053ef33f79580f7d Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 5 Nov 2026 10:11:01 -0700 Subject: [PATCH 4/6] Sample DST offsets instead of assuming a one-hour shift We can't use the _Py_daylight flag to determine the DST offset, it only says if the zone has DST rules. Loop through the current year months, looking for DST offset. --- Doc/library/time.rst | 15 +++++++-- Lib/test/test_time.py | 72 +++++++++++++++++++++++++++++++++++++++++++ Modules/timemodule.c | 52 ++++++++++++++++++++++++++----- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/Doc/library/time.rst b/Doc/library/time.rst index 561c3cb643c99b7..a55aaf140b07265 100644 --- a/Doc/library/time.rst +++ b/Doc/library/time.rst @@ -1030,7 +1030,10 @@ Timezone Constants .. data:: daylight - Nonzero if a DST timezone is defined. See note below. + Nonzero if a DST timezone is defined. On some platforms, this includes + timezones that observed DST only in the past. It does not indicate whether + DST is currently in effect; use :attr:`struct_time.tm_isdst` from + :func:`localtime` for that. See note below. .. data:: timezone @@ -1048,8 +1051,14 @@ Timezone Constants For the above Timezone constants (:data:`altzone`, :data:`daylight`, :data:`timezone`, and :data:`tzname`), the value is determined by the timezone rules in effect at module load time or the last time :func:`tzset` is called and may be incorrect - for times in the past. It is recommended to use the :attr:`~struct_time.tm_gmtoff` and - :attr:`~struct_time.tm_zone` results from :func:`localtime` to obtain timezone information. + for times in the past. On platforms that do not provide a DST offset, + :data:`altzone` is estimated by sampling dates in the current year. The + latest sampled DST offset at or before the current time is preferred; + future months are sampled only if no such offset was found. It may equal + :data:`timezone` even when :data:`daylight` is nonzero if no sampled date + observes DST. It is recommended to use the + :attr:`~struct_time.tm_gmtoff` and :attr:`~struct_time.tm_zone` results from + :func:`localtime` to obtain timezone information. .. seealso:: diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index 88d88c4cae8e4f1..cefbd8321e611b0 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -5,8 +5,10 @@ import fractions import math import platform +import struct import sys import sysconfig +import tempfile import time import threading import unittest @@ -562,6 +564,76 @@ def test_tzset(self): del environ['TZ'] time.tzset() + @support.run_with_tz('LHST-10:30LHDT-11,M10.1.0,M4.1.0') + def test_tzset_half_hour_dst(self): + self.assertEqual(time.timezone, -37800) + self.assertEqual(time.altzone, -39600) + self.assertEqual(time.daylight, 1) + + @unittest.skipUnless(sysconfig.get_config_var('_Py_HAVE_TIMEZONE_GLOBALS'), + 'requires libc timezone globals') + @support.run_with_tz('IST-1GMT0,M10.5.0,M3.5.0/1') + def test_tzset_negative_dst(self): + self.assertEqual(time.timezone, -3600) + self.assertEqual(time.altzone, 0) + self.assertEqual(time.daylight, 1) + self.assertEqual(time.tzname, ('IST', 'GMT')) + + @unittest.skipUnless(sysconfig.get_config_var('_Py_HAVE_TIMEZONE_GLOBALS') + and not sysconfig.get_config_var('HAVE_ALTZONE'), + 'requires sampling the DST offset') + @support.run_with_tz('Asia/Kolkata') + def test_tzset_historical_dst(self): + # Kolkata observed DST during World War II, but not since 1945. + # Check libc's zone data, rather than assuming it is installed. + historical = time.localtime(-862531200) # 1942-09-02 UTC + if (historical.tm_isdst != 1 + or getattr(historical, 'tm_gmtoff', None) != 23400): + self.skipTest('requires historical Asia/Kolkata timezone data') + current = time.localtime() + if (current.tm_isdst != 0 + or getattr(current, 'tm_gmtoff', None) != 19800): + self.skipTest('requires Asia/Kolkata without current DST') + self.assertEqual(time.timezone, -19800) + # libc's daylight may still be nonzero for the historical DST. + self.assertEqual(time.altzone, time.timezone) + + @unittest.skipUnless(sysconfig.get_config_var('_Py_HAVE_TIMEZONE_GLOBALS') + and not sysconfig.get_config_var('HAVE_ALTZONE'), + 'requires sampling the DST offset') + def test_tzset_recent_dst_offset(self): + # A synthetic TZif zone has an old, a current, and a future DST + # offset. Prefer the current offset over both other offsets. + now = int(time.time()) + transitions = (now - 370 * 86400, now - 3600, now + 86400) + names = b'STD\0OLD\0NEW\0FUT\0' + + def header(timecnt, typecnt, charcnt): + return (b'TZif2' + bytes(15) + + struct.pack('>6l', 0, 0, 0, timecnt, typecnt, charcnt)) + + data = (header(0, 1, 4) + struct.pack('>lbb', 0, 0, 0) + b'STD\0' + + header(3, 4, len(names)) + + struct.pack('>3q', *transitions) + bytes((1, 2, 3))) + for offset, isdst, index in ((0, 0, 0), (3600, 1, 4), + (7200, 1, 8), (10800, 1, 12)): + data += struct.pack('>lbb', offset, isdst, index) + data += names + b'\n\n' + + with tempfile.NamedTemporaryFile() as zone: + zone.write(data) + zone.flush() + + @support.run_with_tz(':' + zone.name) + def check(): + local = time.localtime(now) + if getattr(local, 'tm_gmtoff', None) != 7200: + self.skipTest('requires TZif v2 timezone support') + self.assertEqual(local.tm_isdst, 1) + self.assertEqual(time.altzone, -7200) + + check() + def test_insane_timestamps(self): # It's possible that some platform maps time_t to double, # and that this test will fail there. This test should diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 66d2ede9214d704..401fb1fe5aeed2a 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1865,7 +1865,9 @@ get_zone(char *zone, int n, struct tm *p) strftime(zone, n, "%Z", p); #endif } +#endif // !_Py_HAVE_TIMEZONE_GLOBALS +#if !defined(_Py_HAVE_TIMEZONE_GLOBALS) || !defined(HAVE_ALTZONE) static time_t get_gmtoff(time_t t, struct tm *p) { @@ -1875,7 +1877,7 @@ get_gmtoff(time_t t, struct tm *p) return timegm(p) - t; #endif } -#endif // !_Py_HAVE_TIMEZONE_GLOBALS +#endif static int init_timezone(PyObject *m) @@ -1910,14 +1912,11 @@ init_timezone(PyObject *m) tzset(); #endif ADD_INT("timezone", _Py_timezone); -#ifdef HAVE_ALTZONE - ADD_INT("altzone", altzone); -#else - /* libc does not provide the DST offset. Assume a one hour shift when - the zone has DST, otherwise use the standard offset. */ - ADD_INT("altzone", _Py_daylight ? _Py_timezone - 3600 : _Py_timezone); -#endif ADD_INT("daylight", _Py_daylight); +#ifndef HAVE_ALTZONE + long dst_offset = _Py_timezone; + int has_dst = _Py_daylight; +#endif #ifdef MS_WINDOWS TIME_ZONE_INFORMATION tzinfo = {0}; GetTimeZoneInformation(&tzinfo); @@ -1944,6 +1943,43 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", Py_BuildValue("(NN)", otz0, otz1)) < 0) { return -1; } +#ifdef HAVE_ALTZONE + ADD_INT("altzone", altzone); +#else + /* daylight can be nonzero solely because of historical transitions. + Sample roughly monthly through the current year, looking for an + actual DST offset, which need not be one hour from standard time. + Prefer the latest DST sample at or before now; only look ahead if + none has been found by the current month. If none is found at all, + use the standard offset. Like the fallback below, + this is only a heuristic: short DST seasons may not be sampled. + Copy the other globals first, since localtime() may update tzname. */ + if (has_dst) { + static const time_t YEAR = (365 * 24 + 6) * 3600; + time_t now = time((time_t *)0); + struct tm current; + if (_PyTime_localtime(now, ¤t) < 0) { + return -1; + } + time_t t = (now / YEAR) * YEAR; + int found_dst = 0; + for (int month = 0; month < 12; month++, t += YEAR / 12) { + time_t sample = month == current.tm_mon ? now : t; + struct tm p; + if (_PyTime_localtime(sample, &p) < 0) { + return -1; + } + if (p.tm_isdst > 0) { + dst_offset = -get_gmtoff(sample, &p); + found_dst = 1; + } + if (month >= current.tm_mon && found_dst) { + break; + } + } + } + ADD_INT("altzone", dst_offset); +#endif #else // !_Py_HAVE_TIMEZONE_GLOBALS static const time_t YEAR = (365 * 24 + 6) * 3600; time_t t; From 2fd19a054e39e5298c37ba4fb884b0809250034d Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 5 Nov 2026 11:18:53 -0700 Subject: [PATCH 5/6] Use more robust sampling to find DST offset. --- Doc/library/time.rst | 15 +++--- Lib/test/test_time.py | 27 ++++++---- Modules/timemodule.c | 112 ++++++++++++++++++++++++++++++------------ 3 files changed, 104 insertions(+), 50 deletions(-) diff --git a/Doc/library/time.rst b/Doc/library/time.rst index a55aaf140b07265..b968921ca755ad4 100644 --- a/Doc/library/time.rst +++ b/Doc/library/time.rst @@ -1050,15 +1050,12 @@ Timezone Constants For the above Timezone constants (:data:`altzone`, :data:`daylight`, :data:`timezone`, and :data:`tzname`), the value is determined by the timezone rules in effect - at module load time or the last time :func:`tzset` is called and may be incorrect - for times in the past. On platforms that do not provide a DST offset, - :data:`altzone` is estimated by sampling dates in the current year. The - latest sampled DST offset at or before the current time is preferred; - future months are sampled only if no such offset was found. It may equal - :data:`timezone` even when :data:`daylight` is nonzero if no sampled date - observes DST. It is recommended to use the - :attr:`~struct_time.tm_gmtoff` and :attr:`~struct_time.tm_zone` results from - :func:`localtime` to obtain timezone information. + at module load time or the last time :func:`tzset` is called and may be + incorrect for times in the past. On platforms that do not provide a DST + offset, :data:`altzone` is estimated by sampling dates in the current year. + It is recommended to use the :attr:`~struct_time.tm_gmtoff` and + :attr:`~struct_time.tm_zone` results from :func:`localtime` to obtain + timezone information. .. seealso:: diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index cefbd8321e611b0..b595db565899d29 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -599,13 +599,18 @@ def test_tzset_historical_dst(self): self.assertEqual(time.altzone, time.timezone) @unittest.skipUnless(sysconfig.get_config_var('_Py_HAVE_TIMEZONE_GLOBALS') - and not sysconfig.get_config_var('HAVE_ALTZONE'), - 'requires sampling the DST offset') + and not sysconfig.get_config_var('HAVE_ALTZONE') + and sysconfig.get_config_var('HAVE_MKTIME'), + 'requires calendar-based DST offset sampling') def test_tzset_recent_dst_offset(self): # A synthetic TZif zone has an old, a current, and a future DST # offset. Prefer the current offset over both other offsets. now = int(time.time()) - transitions = (now - 370 * 86400, now - 3600, now + 86400) + transitions = ((now - 370 * 86400, 1), (now - 3600, 2), + (now + 86400, 3)) + self.check_tzset_dst_offset(transitions, -7200) + + def check_tzset_dst_offset(self, transitions, expected): names = b'STD\0OLD\0NEW\0FUT\0' def header(timecnt, typecnt, charcnt): @@ -613,8 +618,10 @@ def header(timecnt, typecnt, charcnt): + struct.pack('>6l', 0, 0, 0, timecnt, typecnt, charcnt)) data = (header(0, 1, 4) + struct.pack('>lbb', 0, 0, 0) + b'STD\0' - + header(3, 4, len(names)) - + struct.pack('>3q', *transitions) + bytes((1, 2, 3))) + + header(len(transitions), 4, len(names)) + + struct.pack(f'>{len(transitions)}q', + *(t for t, _ in transitions)) + + bytes(index for _, index in transitions)) for offset, isdst, index in ((0, 0, 0), (3600, 1, 4), (7200, 1, 8), (10800, 1, 12)): data += struct.pack('>lbb', offset, isdst, index) @@ -626,11 +633,11 @@ def header(timecnt, typecnt, charcnt): @support.run_with_tz(':' + zone.name) def check(): - local = time.localtime(now) - if getattr(local, 'tm_gmtoff', None) != 7200: - self.skipTest('requires TZif v2 timezone support') - self.assertEqual(local.tm_isdst, 1) - self.assertEqual(time.altzone, -7200) + for t, index in transitions: + local = time.localtime(t) + if getattr(local, 'tm_gmtoff', None) != index * 3600: + self.skipTest('requires TZif v2 timezone support') + self.assertEqual(time.altzone, expected) check() diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 401fb1fe5aeed2a..37d22eb078d3218 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1867,7 +1867,8 @@ get_zone(char *zone, int n, struct tm *p) } #endif // !_Py_HAVE_TIMEZONE_GLOBALS -#if !defined(_Py_HAVE_TIMEZONE_GLOBALS) || !defined(HAVE_ALTZONE) +#if !defined(_Py_HAVE_TIMEZONE_GLOBALS) || \ + (!defined(HAVE_ALTZONE) && defined(HAVE_MKTIME)) static time_t get_gmtoff(time_t t, struct tm *p) { @@ -1879,6 +1880,79 @@ get_gmtoff(time_t t, struct tm *p) } #endif +#if defined(_Py_HAVE_TIMEZONE_GLOBALS) && !defined(HAVE_ALTZONE) && defined(HAVE_MKTIME) +static long +get_dst_offset(time_t now, long dst_offset) +{ + /* Sample calendar months in the current year, looking for an actual + DST offset, which need not be one hour from standard time. + Prefer the latest DST sample at or before now; only look ahead + if none has been found by the current month. If none is found + at all, use the standard offset. This is only a heuristic: + short DST seasons may not be sampled. */ + struct tm current; + if (now == (time_t)-1) { + return dst_offset; + } + if (_PyTime_localtime(now, ¤t) < 0) { + PyErr_Clear(); + return dst_offset; + } + if (current.tm_mon < 0 || current.tm_mon > 11) { + return dst_offset; + } + + int found_dst = 0; + for (int month = 0; month < 12; month++) { + time_t sample; + struct tm p; + if (month == current.tm_mon) { + sample = now; + p = current; + } + else { + p = (struct tm){ + .tm_year = current.tm_year, + .tm_mon = month, + .tm_mday = 15, + .tm_hour = 12, + .tm_isdst = -1, + .tm_wday = -1, + }; + sample = mktime(&p); + /* As in time_mktime(), -1 alone does not imply failure. */ + if (sample == (time_t)-1 && p.tm_wday == -1) { + break; + } + if (_PyTime_localtime(sample, &p) < 0) { + PyErr_Clear(); + break; + } + /* mktime() may normalize a nonexistent local date. */ + if (p.tm_year != current.tm_year || p.tm_mon != month + || (month < current.tm_mon && sample > now) + || (month > current.tm_mon && sample < now)) + { + break; + } + } + if (p.tm_isdst > 0) { + errno = 0; + time_t gmtoff = get_gmtoff(sample, &p); + if (errno != 0 || gmtoff < -48 * 3600 || gmtoff > 48 * 3600) { + break; + } + dst_offset = -(long)gmtoff; + found_dst = 1; + } + if (month >= current.tm_mon && found_dst) { + break; + } + } + return dst_offset; +} +#endif + static int init_timezone(PyObject *m) { @@ -1915,8 +1989,10 @@ init_timezone(PyObject *m) ADD_INT("daylight", _Py_daylight); #ifndef HAVE_ALTZONE long dst_offset = _Py_timezone; +#ifdef HAVE_MKTIME int has_dst = _Py_daylight; #endif +#endif #ifdef MS_WINDOWS TIME_ZONE_INFORMATION tzinfo = {0}; GetTimeZoneInformation(&tzinfo); @@ -1946,38 +2022,12 @@ init_timezone(PyObject *m) #ifdef HAVE_ALTZONE ADD_INT("altzone", altzone); #else - /* daylight can be nonzero solely because of historical transitions. - Sample roughly monthly through the current year, looking for an - actual DST offset, which need not be one hour from standard time. - Prefer the latest DST sample at or before now; only look ahead if - none has been found by the current month. If none is found at all, - use the standard offset. Like the fallback below, - this is only a heuristic: short DST seasons may not be sampled. - Copy the other globals first, since localtime() may update tzname. */ +#ifdef HAVE_MKTIME if (has_dst) { - static const time_t YEAR = (365 * 24 + 6) * 3600; - time_t now = time((time_t *)0); - struct tm current; - if (_PyTime_localtime(now, ¤t) < 0) { - return -1; - } - time_t t = (now / YEAR) * YEAR; - int found_dst = 0; - for (int month = 0; month < 12; month++, t += YEAR / 12) { - time_t sample = month == current.tm_mon ? now : t; - struct tm p; - if (_PyTime_localtime(sample, &p) < 0) { - return -1; - } - if (p.tm_isdst > 0) { - dst_offset = -get_gmtoff(sample, &p); - found_dst = 1; - } - if (month >= current.tm_mon && found_dst) { - break; - } - } + // Sampling is best-effort: retain the last offset on failure. + dst_offset = get_dst_offset(time(NULL), dst_offset); } +#endif ADD_INT("altzone", dst_offset); #endif #else // !_Py_HAVE_TIMEZONE_GLOBALS From a612d89e55e4544e04bd685e8c1ca34c0d353df0 Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 5 Nov 2026 18:53:11 -0700 Subject: [PATCH 6/6] Address review comments for libc timezone globals Rewrite test_strptime.test_timezone to check each tzname entry and treat UTC/GMT as standard time, so zones with negative DST such as Europe/Dublin pass. Add a round-trip test with fixed EST5EDT rules. Document the glibc tzset() early-return caveat in init_timezone(). In get_dst_offset(), skip a normalized sample date instead of abandoning the remaining months, and drop a dead tm_mon range check. Expand the NEWS entry to cover the negative DST classification change, daylight being nonzero for historical DST, and altzone sampling on glibc and Windows. --- Lib/test/test_strptime.py | 41 +++++++++++++------ ...-11-04-12-13-21.gh-issue-161194.GiNgY1.rst | 11 ++++- Modules/timemodule.c | 11 +++-- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index d70837ea63f5474..b6efc030d76041f 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -474,18 +474,35 @@ def test_timezone(self): self.assertEqual(strp_output.tm_isdst, 0) strp_output = _strptime._strptime_time("GMT", "%Z") self.assertEqual(strp_output.tm_isdst, 0) - time_tuple = time.localtime() - strf_output = time.strftime("%Z") #UTC does not have a timezone - strp_output = _strptime._strptime_time(strf_output, "%Z") - locale_time = _strptime.LocaleTime() - if time.tzname[0] != time.tzname[1] or not time.daylight: - self.assertTrue(strp_output[8] == time_tuple[8], - "timezone check failed; '%s' -> %s != %s" % - (strf_output, strp_output[8], time_tuple[8])) - else: - self.assertTrue(strp_output[8] == -1, - "LocaleTime().timezone has duplicate values and " - "time.daylight but timezone value not set to -1") + # %Z recognizes the module's timezone names. UTC and GMT are always + # parsed as standard time, even in zones such as Europe/Dublin where + # libc reports GMT as (negative) DST. + for isdst, name in enumerate(time.tzname): + if not name or (isdst and not time.daylight): + continue + with self.subTest(name=name, isdst=isdst): + if name.lower() in ('utc', 'gmt'): + expected = 0 + elif time.daylight and time.tzname[0] == time.tzname[1]: + expected = -1 + else: + expected = isdst + parsed = _strptime._strptime_time(name, '%Z') + self.assertEqual(parsed.tm_isdst, expected) + + @support.run_with_tz('EST5EDT,M3.2.0,M11.1.0') + @skip_if_buggy_ucrt_strfptime + def test_timezone_roundtrip(self): + # Use stable, explicit rules when comparing strftime(), strptime(), + # and localtime(), including both standard time and DST. + for month, isdst in ((1, 0), (7, 1)): + with self.subTest(month=month): + t = time.mktime((2000, month, 1, 12, 0, 0, 0, 0, -1)) + local = time.localtime(t) + self.assertEqual(local.tm_isdst, isdst) + name = time.strftime('%Z', local) + parsed = _strptime._strptime_time(name, '%Z') + self.assertEqual(parsed.tm_isdst, isdst) @unittest.skipUnless( hasattr(time, "tzset"), "time module has no attribute tzset" diff --git a/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst b/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst index b498ad17519e568..57218f354b23892 100644 --- a/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst +++ b/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst @@ -3,5 +3,12 @@ On platforms where libc provides the ``timezone``, ``daylight`` and :data:`time.tzname` are now taken from libc instead of being guessed from samples of the local time in January and July. This fixes stale values for zones whose rules change during the year, such as ``America/Vancouver`` in -2026. :data:`time.altzone` now equals :data:`time.timezone` for zones -without DST when libc does not provide ``altzone``. +2026. Standard and DST are now classified by libc rather than by comparing +offsets, so for zones with negative DST such as ``Europe/Dublin``, +:data:`time.tzname` is now ``('IST', 'GMT')`` and :data:`time.timezone` is +``-3600``, matching :attr:`~time.struct_time.tm_isdst`. On some platforms +:data:`time.daylight` may now be nonzero for zones that observed DST only in +the past. When libc does not provide ``altzone`` (including glibc and +Windows), :data:`time.altzone` is now found by sampling the current year for +a DST offset instead of assuming a one-hour shift, and equals +:data:`time.timezone` for zones without DST. diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 37d22eb078d3218..dc991b19e56b34d 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -1898,9 +1898,6 @@ get_dst_offset(time_t now, long dst_offset) PyErr_Clear(); return dst_offset; } - if (current.tm_mon < 0 || current.tm_mon > 11) { - return dst_offset; - } int found_dst = 0; for (int month = 0; month < 12; month++) { @@ -1933,7 +1930,7 @@ get_dst_offset(time_t now, long dst_offset) || (month < current.tm_mon && sample > now) || (month > current.tm_mon && sample < now)) { - break; + continue; } } if (p.tm_isdst > 0) { @@ -1983,6 +1980,12 @@ init_timezone(PyObject *m) #ifdef _Py_HAVE_TIMEZONE_GLOBALS PyObject *otz0, *otz1; #if !defined(MS_WINDOWS) || defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM) + /* glibc caveat: tzset() returns early if TZ is unchanged since the + last call, and localtime() updates tzname and daylight for the rules + in effect at the converted time. So when time.tzset() is called + without changing TZ, the values read here reflect the most recent + localtime() call rather than a fresh read of the zone data. There + is no portable way to force a re-read. */ tzset(); #endif ADD_INT("timezone", _Py_timezone);