https://github.com/python/cpython/commit/f5685a266b252455e03ef8e6055eaf4007ec749d
commit: f5685a266b252455e03ef8e6055eaf4007ec749d
branch: main
author: Peter Gessler <[email protected]>
committer: vstinner <[email protected]>
date: 2026-01-15T10:51:11+01:00
summary:

gh-80620: Support negative timestamps on windows in `time.gmtime`, 
`time.localtime`, and `datetime` module (#143463)

Previously, negative timestamps (representing dates before 1970-01-01) were
not supported on Windows due to platform limitations. The changes introduce a
fallback implementation using the Windows FILETIME API, allowing negative
timestamps to be correctly handled in both UTC and local time conversions.
Additionally, related test code is updated to remove Windows-specific skips
and error handling, ensuring consistent behavior across platforms.

Co-authored-by: Victor Stinner <[email protected]>

files:
A Misc/NEWS.d/next/Windows/2026-01-05-21-36-58.gh-issue-80620.p1bD58.rst
M Lib/test/datetimetester.py
M Lib/test/test_time.py
M Modules/_datetimemodule.c
M Python/pytime.c

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 715aaf384a309c..ce13f3a22f3ae5 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..2b1488911ef97b 100644
--- a/Python/pytime.c
+++ b/Python/pytime.c
@@ -273,6 +273,89 @@ _PyTime_AsCLong(PyTime_t t, long *t2)
     *t2 = (long)t;
     return 0;
 }
+
+// Seconds between 1601-01-01 and 1970-01-01:
+// 369 years + 89 leap days.
+#define SECS_BETWEEN_EPOCHS 11644473600LL
+#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 +965,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 +1323,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 +1366,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

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to