From 52390a4b58045e8bd8a8cb85662acf3c6265392f Mon Sep 17 00:00:00 2001 From: Neil Schemenauer Date: Thu, 5 Nov 2026 11:27:35 -0700 Subject: [PATCH] gh-161194: Use libc timezone globals when available --- Doc/library/time.rst | 4 +- Lib/test/test_strptime.py | 40 +++++++++++------ Lib/test/test_time.py | 32 +++++++++++--- ...-00-00.gh-issue-161194.tz-libc-globals.rst | 6 +++ Modules/timemodule.c | 11 ++--- PC/pyconfig.h | 5 +-- configure | 43 +++++++++++++++++++ configure.ac | 16 +++++++ pyconfig.h.in | 3 ++ 9 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-11-04-12-00-00.gh-issue-161194.tz-libc-globals.rst diff --git a/Doc/library/time.rst b/Doc/library/time.rst index 561c3cb643c99b7..78ff76f7581cebc 100644 --- a/Doc/library/time.rst +++ b/Doc/library/time.rst @@ -1026,7 +1026,9 @@ Timezone Constants The offset of the local DST timezone, in seconds west of UTC, if one is defined. This is negative if the local DST timezone is east of UTC (as in Western Europe, - including the UK). Only use this if ``daylight`` is nonzero. See note below. + including the UK). Only use this if ``daylight`` is nonzero. On some + platforms, this is approximated as :data:`timezone` minus 3600, which may + be incorrect for timezones with a different DST shift. See note below. .. data:: daylight diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py index d70837ea63f5474..aceefde70477f2d 100644 --- a/Lib/test/test_strptime.py +++ b/Lib/test/test_strptime.py @@ -474,18 +474,34 @@ 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, which need not match + # localtime() for every date in a zone whose rules have changed. + 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/Lib/test/test_time.py b/Lib/test/test_time.py index dac4dd9060ccdd1..84f5bde64af9378 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -561,6 +561,16 @@ def test_tzset(self): del environ['TZ'] time.tzset() + @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): + # Prefer libc's standard/DST classification over ordering offsets. + # altzone may still assume a one-hour positive DST shift. + self.assertEqual(time.timezone, -3600) + self.assertEqual(time.tzname, ('IST', 'GMT')) + self.assertEqual(time.daylight, 1) + 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 @@ -858,17 +868,10 @@ def test_localtime_timezone(self): lt = time.localtime() self.assertHasAttr(lt, "tm_gmtoff") self.assertHasAttr(lt, "tm_zone") - - # See if the offset and zone are similar to the module - # attributes. if lt.tm_gmtoff is None: self.assertNotHasAttr(time, "timezone") - else: - self.assertEqual(lt.tm_gmtoff, -[time.timezone, time.altzone][lt.tm_isdst]) if lt.tm_zone is None: self.assertNotHasAttr(time, "tzname") - else: - self.assertEqual(lt.tm_zone, time.tzname[lt.tm_isdst]) # Try and make UNIX times from the localtime and a 9-tuple # created from the localtime. Test to see that the times are @@ -886,6 +889,21 @@ def test_localtime_timezone(self): self.assertEqual(new_lt.tm_gmtoff, lt.tm_gmtoff) self.assertEqual(new_lt9.tm_zone, lt.tm_zone) + @support.run_with_tz('EST5EDT,M3.2.0,M11.1.0') + @skip_if_buggy_ucrt_strfptime + def test_localtime_timezone_constants(self): + # The module constants cannot describe every historical or current + # offset in zones whose rules have changed. Compare them against + # localtime() using explicit rules with a one-hour DST shift. + 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)) + lt = time.localtime(t) + self.assertEqual(lt.tm_isdst, isdst) + self.assertEqual(lt.tm_gmtoff, + -(time.timezone, time.altzone)[isdst]) + self.assertEqual(lt.tm_zone, time.tzname[isdst]) + def test_strptime_timezone(self): t = time.strptime("UTC", "%Z") self.assertEqual(t.tm_zone, 'UTC') diff --git a/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.gh-issue-161194.tz-libc-globals.rst b/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.gh-issue-161194.tz-libc-globals.rst new file mode 100644 index 000000000000000..02fe281ef038d46 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-04-12-00-00.gh-issue-161194.tz-libc-globals.rst @@ -0,0 +1,6 @@ +Use libc's ``timezone``, ``daylight`` and ``tzname`` variables for the +:mod:`time` module constants when available, rather than estimating them +from January and July samples. This fixes stale constants after timezone +rule changes and the standard/DST classification for negative DST zones. +The existing one-hour approximation for :data:`time.altzone` is unchanged +on platforms without a native ``altzone`` variable. diff --git a/Modules/timemodule.c b/Modules/timemodule.c index 064ab2cf2364f1a..4b38476935afbfe 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 _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_DECL_TZNAME +#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_DECL_TZNAME +#ifdef _Py_HAVE_TIMEZONE_GLOBALS PyObject *otz0, *otz1; #if !defined(MS_WINDOWS) || defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM) tzset(); @@ -1913,6 +1913,7 @@ init_timezone(PyObject *m) #ifdef HAVE_ALTZONE ADD_INT("altzone", altzone); #else + /* Assume a one-hour DST shift when libc does not provide altzone. */ ADD_INT("altzone", _Py_timezone-3600); #endif ADD_INT("daylight", _Py_daylight); @@ -1942,7 +1943,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", Py_BuildValue("(NN)", otz0, otz1)) < 0) { return -1; } -#else // !HAVE_DECL_TZNAME +#else // !_Py_HAVE_TIMEZONE_GLOBALS static const time_t YEAR = (365 * 24 + 6) * 3600; time_t t; struct tm p; @@ -1987,7 +1988,7 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", tzname_obj) < 0) { return -1; } -#endif // !HAVE_DECL_TZNAME +#endif // !_Py_HAVE_TIMEZONE_GLOBALS #undef ADD_INT if (PyErr_Occurred()) { diff --git a/PC/pyconfig.h b/PC/pyconfig.h index f969525c1db5c7c..328b61b5227d4f5 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 _Py_HAVE_TIMEZONE_GLOBALS 1 /* Define if you have dirent.h. */ /* #define DIRENT 1 */ diff --git a/configure b/configure index fca5669c3756b4d..6b4f6ae096643ff 100755 --- a/configure +++ b/configure @@ -27089,6 +27089,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 _Py_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 753f997a20da4a2..5989416ded05a9e 100644 --- a/configure.ac +++ b/configure.ac @@ -6480,6 +6480,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([_Py_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 dba7d193bcb3d19..eb56d343e985523 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -2173,6 +2173,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