diff --git a/Doc/library/time.rst b/Doc/library/time.rst index 561c3cb643c99b7..b968921ca755ad4 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 @@ -1047,9 +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. 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_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/Lib/test/test_time.py b/Lib/test/test_time.py index dac4dd9060ccdd1..b595db565899d29 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 @@ -509,7 +511,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 +521,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 @@ -561,6 +564,83 @@ 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') + 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, 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): + 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(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) + data += names + b'\n\n' + + with tempfile.NamedTemporaryFile() as zone: + zone.write(data) + zone.flush() + + @support.run_with_tz(':' + zone.name) + def check(): + 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() + 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/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 new file mode 100644 index 000000000000000..57218f354b23892 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-11-04-12-13-21.gh-issue-161194.GiNgY1.rst @@ -0,0 +1,14 @@ +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. 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 064ab2cf2364f1a..dc991b19e56b34d 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) { @@ -1865,7 +1865,10 @@ 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) && defined(HAVE_MKTIME)) static time_t get_gmtoff(time_t t, struct tm *p) { @@ -1875,7 +1878,77 @@ get_gmtoff(time_t t, struct tm *p) return timegm(p) - t; #endif } -#endif // !HAVE_DECL_TZNAME +#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; + } + + 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)) + { + continue; + } + } + 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) @@ -1904,18 +1977,25 @@ 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) + /* 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); -#ifdef HAVE_ALTZONE - ADD_INT("altzone", altzone); -#else - ADD_INT("altzone", _Py_timezone-3600); -#endif 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); @@ -1942,7 +2022,18 @@ init_timezone(PyObject *m) if (PyModule_Add(m, "tzname", Py_BuildValue("(NN)", otz0, otz1)) < 0) { return -1; } -#else // !HAVE_DECL_TZNAME +#ifdef HAVE_ALTZONE + ADD_INT("altzone", altzone); +#else +#ifdef HAVE_MKTIME + if (has_dst) { + // 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 static const time_t YEAR = (365 * 24 + 6) * 3600; time_t t; struct tm p; @@ -1987,7 +2078,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