diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index ace56aab7aceba..8d39299b3ff442 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -2706,24 +2706,20 @@ def utcfromtimestamp(*args, **kwargs): self.assertEqual(zero.second, 0) self.assertEqual(zero.microsecond, 0) one = fts(1e-6) - try: - minus_one = fts(-1e-6) - except OSError: - # localtime(-1) and gmtime(-1) is not supported on Windows - pass - else: - self.assertEqual(minus_one.second, 59) - self.assertEqual(minus_one.microsecond, 999999) - - t = fts(-1e-8) - self.assertEqual(t, zero) - t = fts(-9e-7) - self.assertEqual(t, minus_one) - t = fts(-1e-7) - self.assertEqual(t, zero) - t = fts(-1/2**7) - self.assertEqual(t.second, 59) - self.assertEqual(t.microsecond, 992188) + minus_one = fts(-1e-6) + + self.assertEqual(minus_one.second, 59) + self.assertEqual(minus_one.microsecond, 999999) + + t = fts(-1e-8) + self.assertEqual(t, zero) + t = fts(-9e-7) + self.assertEqual(t, minus_one) + t = fts(-1e-7) + self.assertEqual(t, zero) + t = fts(-1/2**7) + self.assertEqual(t.second, 59) + self.assertEqual(t.microsecond, 992188) t = fts(1e-7) self.assertEqual(t, zero) @@ -2752,22 +2748,18 @@ def utcfromtimestamp(*args, **kwargs): self.assertEqual(zero.second, 0) self.assertEqual(zero.microsecond, 0) one = fts(D('0.000_001')) - try: - minus_one = fts(D('-0.000_001')) - except OSError: - # localtime(-1) and gmtime(-1) is not supported on Windows - pass - else: - self.assertEqual(minus_one.second, 59) - self.assertEqual(minus_one.microsecond, 999_999) + minus_one = fts(D('-0.000_001')) + + self.assertEqual(minus_one.second, 59) + self.assertEqual(minus_one.microsecond, 999_999) - t = fts(D('-0.000_000_1')) - self.assertEqual(t, zero) - t = fts(D('-0.000_000_9')) - self.assertEqual(t, minus_one) - t = fts(D(-1)/2**7) - self.assertEqual(t.second, 59) - self.assertEqual(t.microsecond, 992188) + t = fts(D('-0.000_000_1')) + self.assertEqual(t, zero) + t = fts(D('-0.000_000_9')) + self.assertEqual(t, minus_one) + t = fts(D(-1)/2**7) + self.assertEqual(t.second, 59) + self.assertEqual(t.microsecond, 992188) t = fts(D('0.000_000_1')) self.assertEqual(t, zero) @@ -2803,22 +2795,18 @@ def utcfromtimestamp(*args, **kwargs): self.assertEqual(zero.second, 0) self.assertEqual(zero.microsecond, 0) one = fts(F(1, 1_000_000)) - try: - minus_one = fts(F(-1, 1_000_000)) - except OSError: - # localtime(-1) and gmtime(-1) is not supported on Windows - pass - else: - self.assertEqual(minus_one.second, 59) - self.assertEqual(minus_one.microsecond, 999_999) + minus_one = fts(F(-1, 1_000_000)) - t = fts(F(-1, 10_000_000)) - self.assertEqual(t, zero) - t = fts(F(-9, 10_000_000)) - self.assertEqual(t, minus_one) - t = fts(F(-1, 2**7)) - self.assertEqual(t.second, 59) - self.assertEqual(t.microsecond, 992188) + self.assertEqual(minus_one.second, 59) + self.assertEqual(minus_one.microsecond, 999_999) + + t = fts(F(-1, 10_000_000)) + self.assertEqual(t, zero) + t = fts(F(-9, 10_000_000)) + self.assertEqual(t, minus_one) + t = fts(F(-1, 2**7)) + self.assertEqual(t.second, 59) + self.assertEqual(t.microsecond, 992188) t = fts(F(1, 10_000_000)) self.assertEqual(t, zero) @@ -2860,6 +2848,7 @@ def test_timestamp_limits(self): # If that assumption changes, this value can change as well self.assertEqual(max_ts, 253402300799.0) + @unittest.skipIf(sys.platform == "win32", "Windows doesn't support min timestamp") def test_fromtimestamp_limits(self): try: self.theclass.fromtimestamp(-2**32 - 1) @@ -2899,6 +2888,7 @@ def test_fromtimestamp_limits(self): # OverflowError, especially on 32-bit platforms. self.theclass.fromtimestamp(ts) + @unittest.skipIf(sys.platform == "win32", "Windows doesn't support min timestamp") def test_utcfromtimestamp_limits(self): with self.assertWarns(DeprecationWarning): try: @@ -2960,13 +2950,11 @@ def test_insane_utcfromtimestamp(self): self.assertRaises(OverflowError, self.theclass.utcfromtimestamp, insane) - @unittest.skipIf(sys.platform == "win32", "Windows doesn't accept negative timestamps") def test_negative_float_fromtimestamp(self): # The result is tz-dependent; at least test that this doesn't # fail (like it did before bug 1646728 was fixed). self.theclass.fromtimestamp(-1.05) - @unittest.skipIf(sys.platform == "win32", "Windows doesn't accept negative timestamps") def test_negative_float_utcfromtimestamp(self): with self.assertWarns(DeprecationWarning): d = self.theclass.utcfromtimestamp(-1.05) diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index c7e81fff6f776b..3f8952ae8c9a5c 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -187,6 +187,27 @@ def test_epoch(self): # Only test the date and time, ignore other gmtime() members self.assertEqual(tuple(epoch)[:6], (1970, 1, 1, 0, 0, 0), epoch) + def test_gmtime(self): + # expected format: + # (tm_year, tm_mon, tm_mday, + # tm_hour, tm_min, tm_sec, + # tm_wday, tm_yday) + for t, expected in ( + (-13262400, (1969, 7, 31, 12, 0, 0, 3, 212)), + (-6177600, (1969, 10, 21, 12, 0, 0, 1, 294)), + # non-leap years (pre epoch) + (-2203891200, (1900, 3, 1, 0, 0, 0, 3, 60)), + (-2203977600, (1900, 2, 28, 0, 0, 0, 2, 59)), + (-5359564800, (1800, 3, 1, 0, 0, 0, 5, 60)), + (-5359651200, (1800, 2, 28, 0, 0, 0, 4, 59)), + # leap years (pre epoch) + (-2077660800, (1904, 3, 1, 0, 0, 0, 1, 61)), + (-2077833600, (1904, 2, 28, 0, 0, 0, 6, 59)), + ): + with self.subTest(t=t, expected=expected): + res = time.gmtime(t) + self.assertEqual(tuple(res)[:8], expected, res) + def test_strftime(self): tt = time.gmtime(self.t) for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'H', 'I', @@ -501,12 +522,13 @@ def test_localtime_without_arg(self): def test_mktime(self): # Issue #1726687 for t in (-2, -1, 0, 1): + t_struct = time.localtime(t) try: - tt = time.localtime(t) + t1 = time.mktime(t_struct) except (OverflowError, OSError): pass else: - self.assertEqual(time.mktime(tt), t) + self.assertEqual(t1, t) # Issue #13309: passing extreme values to mktime() or localtime() # borks the glibc's internal timezone data. diff --git a/Misc/NEWS.d/next/Windows/2026-01-05-21-36-58.gh-issue-80620.p1bD58.rst b/Misc/NEWS.d/next/Windows/2026-01-05-21-36-58.gh-issue-80620.p1bD58.rst new file mode 100644 index 00000000000000..fb2f500bc45234 --- /dev/null +++ b/Misc/NEWS.d/next/Windows/2026-01-05-21-36-58.gh-issue-80620.p1bD58.rst @@ -0,0 +1 @@ +Support negative timestamps in :func:`time.gmtime`, :func:`time.localtime`, and various :mod:`datetime` functions. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 46c4f57984b0df..8f64e572bd6086 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -5584,22 +5584,7 @@ datetime_from_timet_and_us(PyTypeObject *cls, TM_FUNC f, time_t timet, int us, second = Py_MIN(59, tm.tm_sec); /* local timezone requires to compute fold */ - if (tzinfo == Py_None && f == _PyTime_localtime - /* On Windows, passing a negative value to local results - * in an OSError because localtime_s on Windows does - * not support negative timestamps. Unfortunately this - * means that fold detection for time values between - * 0 and max_fold_seconds will result in an identical - * error since we subtract max_fold_seconds to detect a - * fold. However, since we know there haven't been any - * folds in the interval [0, max_fold_seconds) in any - * timezone, we can hackily just forego fold detection - * for this time range. - */ -#ifdef MS_WINDOWS - && (timet - max_fold_seconds > 0) -#endif - ) { + if (tzinfo == Py_None && f == _PyTime_localtime) { long long probe_seconds, result_seconds, transition; result_seconds = utc_to_seconds(year, month, day, diff --git a/Python/pytime.c b/Python/pytime.c index 2f3d854428b4bf..1f48984329a5b8 100644 --- a/Python/pytime.c +++ b/Python/pytime.c @@ -273,6 +273,88 @@ _PyTime_AsCLong(PyTime_t t, long *t2) *t2 = (long)t; return 0; } + +// 369 years + 89 leap days +#define SECS_BETWEEN_EPOCHS 11644473600LL /* Seconds between 1601-01-01 and 1970-01-01 */ +#define HUNDRED_NS_PER_SEC 10000000LL + +// Calculate day of year (0-365) from SYSTEMTIME +static int +_PyTime_calc_yday(const SYSTEMTIME *st) +{ + // Cumulative days before each month (non-leap year) + static const int days_before_month[] = { + 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 + }; + int yday = days_before_month[st->wMonth - 1] + st->wDay - 1; + // Account for leap day if we're past February in a leap year. + if (st->wMonth > 2) { + // Leap year rules (Gregorian calendar): + // - Years divisible by 4 are leap years + // - EXCEPT years divisible by 100 are NOT leap years + // - EXCEPT years divisible by 400 ARE leap years + int year = st->wYear; + int is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + yday += is_leap; + } + return yday; +} + +// Convert time_t to struct tm using Windows FILETIME API. +// If is_local is true, convert to local time. */ +// Fallback for negative timestamps that localtime_s/gmtime_s cannot handle. +// Return 0 on success. Return -1 on error. +static int +_PyTime_windows_filetime(time_t timer, struct tm *tm, int is_local) +{ + /* Check for underflow - FILETIME epoch is 1601-01-01 */ + if (timer < -SECS_BETWEEN_EPOCHS) { + PyErr_SetString(PyExc_OverflowError, "timestamp out of range for Windows FILETIME"); + return -1; + } + + /* Convert time_t to FILETIME (100-nanosecond intervals since 1601-01-01) */ + ULONGLONG ticks = ((ULONGLONG)timer + SECS_BETWEEN_EPOCHS) * HUNDRED_NS_PER_SEC; + FILETIME ft; + ft.dwLowDateTime = (DWORD)(ticks); // cast to DWORD truncates to low 32 bits + ft.dwHighDateTime = (DWORD)(ticks >> 32); + + /* Convert FILETIME to SYSTEMTIME */ + SYSTEMTIME st_result; + if (is_local) { + /* Convert to local time */ + FILETIME ft_local; + if (!FileTimeToLocalFileTime(&ft, &ft_local) || + !FileTimeToSystemTime(&ft_local, &st_result)) { + PyErr_SetFromWindowsErr(0); + return -1; + } + } + else { + /* Convert to UTC */ + if (!FileTimeToSystemTime(&ft, &st_result)) { + PyErr_SetFromWindowsErr(0); + return -1; + } + } + + /* Convert SYSTEMTIME to struct tm */ + tm->tm_year = st_result.wYear - 1900; + tm->tm_mon = st_result.wMonth - 1; /* SYSTEMTIME: 1-12, tm: 0-11 */ + tm->tm_mday = st_result.wDay; + tm->tm_hour = st_result.wHour; + tm->tm_min = st_result.wMinute; + tm->tm_sec = st_result.wSecond; + tm->tm_wday = st_result.wDayOfWeek; /* 0=Sunday */ + + // `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; + + return 0; +} #endif @@ -882,10 +964,8 @@ py_get_system_clock(PyTime_t *tp, _Py_clock_info_t *info, int raise_exc) GetSystemTimePreciseAsFileTime(&system_time); large.u.LowPart = system_time.dwLowDateTime; large.u.HighPart = system_time.dwHighDateTime; - /* 11,644,473,600,000,000,000: number of nanoseconds between - the 1st january 1601 and the 1st january 1970 (369 years + 89 leap - days). */ - PyTime_t ns = (large.QuadPart - 116444736000000000) * 100; + + PyTime_t ns = (large.QuadPart - SECS_BETWEEN_EPOCHS * HUNDRED_NS_PER_SEC) * 100; *tp = ns; if (info) { // GetSystemTimePreciseAsFileTime() is implemented using @@ -1242,15 +1322,19 @@ int _PyTime_localtime(time_t t, struct tm *tm) { #ifdef MS_WINDOWS - int error; - - error = localtime_s(tm, &t); - if (error != 0) { - errno = error; - PyErr_SetFromErrno(PyExc_OSError); - return -1; + if (t >= 0) { + /* For non-negative timestamps, use localtime_s() */ + int error = localtime_s(tm, &t); + if (error != 0) { + errno = error; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + return 0; } - return 0; + + /* For negative timestamps, use FILETIME-based conversion */ + return _PyTime_windows_filetime(t, tm, 1); #else /* !MS_WINDOWS */ #if defined(_AIX) && (SIZEOF_TIME_T < 8) @@ -1281,15 +1365,19 @@ int _PyTime_gmtime(time_t t, struct tm *tm) { #ifdef MS_WINDOWS - int error; - - error = gmtime_s(tm, &t); - if (error != 0) { - errno = error; - PyErr_SetFromErrno(PyExc_OSError); - return -1; + /* For non-negative timestamps, use gmtime_s() */ + if (t >= 0) { + int error = gmtime_s(tm, &t); + if (error != 0) { + errno = error; + PyErr_SetFromErrno(PyExc_OSError); + return -1; + } + return 0; } - return 0; + + /* For negative timestamps, use FILETIME-based conversion */ + return _PyTime_windows_filetime(t, tm, 0); #else /* !MS_WINDOWS */ if (gmtime_r(&t, tm) == NULL) { #ifdef EINVAL