From 9af08c24286ba50d1bb61dcc6a9a67bdb4f14592 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Sun, 8 Nov 2026 19:29:31 -0800 Subject: [PATCH] gh-159739: Preserve large years in time conversions (GH-160175) * gh-159739: Preserve large years in time conversions The time module added 1900 to the C library year using int arithmetic, which overflowed for timestamps whose calendar year exceeds INT_MAX. On platforms that accept these timestamps, gmtime(), localtime(), and ctime() could report a negative year; perform the addition in a wider type. Backport to 3.15 and 3.14. * gh-159739: Round-trip large years and validate datetime timestamps Time tuple consumers parsed calendar years as C int before subtracting 1900, so they rejected valid struct_time results. Date and datetime timestamp conversion could also overflow before reporting the invalid year. Preserve the full time tuple range and report oversized datetime years correctly. Backport to 3.15 and 3.14. (cherry picked from commit e1a4c150e7416ec89df0be6526741d3aac93733e) Co-authored-by: Jelle Zijlstra --- Lib/test/datetimetester.py | 9 ++++++ Lib/test/test_time.py | 28 +++++++++++++++---- ...-10-22-03-55-33.gh-issue-159739.ce26e1.rst | 4 +++ Modules/_datetimemodule.c | 22 +++++++++++++-- Modules/timemodule.c | 20 ++++++------- 5 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 2440c9294d4674..88892c510de4ab 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1463,6 +1463,15 @@ def test_insane_fromtimestamp(self): self.assertRaises(OverflowError, self.theclass.fromtimestamp, insane) + def test_fromtimestamp_large_year(self): + timestamp = 67_767_976_249_084_800 # Year 2147483648. + try: + year = _time.localtime(timestamp).tm_year + except (OverflowError, OSError): + self.skipTest('timestamp out of range on this platform') + with self.assertRaisesRegex(ValueError, rf'\b{year}\b'): + self.theclass.fromtimestamp(timestamp) + def test_fromtimestamp_with_none_arg(self): # See gh-120268 for more details with self.assertRaises(TypeError): diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py index ff7d982fb3621c..266d9b298c2ae3 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -20,10 +20,11 @@ from test.support import skip_if_buggy_ucrt_strfptime, SuppressCrashReport -# Max year is only limited by the size of C int. +# Years are limited by the range of struct tm's int tm_year field. SIZEOF_INT = sysconfig.get_config_var('SIZEOF_INT') or 4 -TIME_MAXYEAR = (1 << 8 * SIZEOF_INT - 1) - 1 -TIME_MINYEAR = -TIME_MAXYEAR - 1 + 1900 +C_INT_MAX = (1 << 8 * SIZEOF_INT - 1) - 1 +TIME_MAXYEAR = C_INT_MAX + 1900 +TIME_MINYEAR = -C_INT_MAX - 1 + 1900 SEC_TO_US = 10 ** 6 US_TO_NS = 10 ** 3 @@ -152,6 +153,23 @@ def test_clock_settime(self): self.assertRaises(OSError, time.clock_settime, time.CLOCK_MONOTONIC, 0) + def test_large_timestamp_year(self): + # 2147483648-06-29 00:00:00 UTC, away from local year boundaries. + timestamp = 67_767_976_249_084_800 + for convert in (time.gmtime, time.localtime, time.ctime): + with self.subTest(convert=convert): + try: + result = convert(timestamp) + except (OverflowError, OSError): + self.skipTest('timestamp out of range on this platform') + if isinstance(result, str): + year = int(result.rsplit(' ', 1)[-1]) + else: + year = result.tm_year + self.assertEqual(year, 2**31) + if convert is time.localtime: + self.assertEqual(time.asctime(result), time.ctime(timestamp)) + def test_conversions(self): self.assertEqual(time.ctime(self.t), time.asctime(time.localtime(self.t))) @@ -360,7 +378,7 @@ def test_strptime_leap_year(self): def test_asctime(self): time.asctime(time.gmtime(self.t)) - # Max year is only limited by the size of C int. + # The year offset must fit into a C int. for bigyear in TIME_MAXYEAR, TIME_MINYEAR: asc = time.asctime((bigyear, 6, 1) + (0,) * 6) self.assertEqual(asc[-len(str(bigyear)):], str(bigyear)) @@ -730,7 +748,7 @@ def test_year(self, fmt=None, func=None): def test_large_year(self): self.assertEqual(self.yearstr(12345).lstrip('+'), '12345') self.assertEqual(self.yearstr(123456789).lstrip('+'), '123456789') - self.assertEqual(self.yearstr(TIME_MAXYEAR).lstrip('+'), str(TIME_MAXYEAR)) + self.assertEqual(self.yearstr(C_INT_MAX).lstrip('+'), str(C_INT_MAX)) self.assertRaises(OverflowError, self.yearstr, TIME_MAXYEAR + 1) def test_negative(self): diff --git a/Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst b/Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst new file mode 100644 index 00000000000000..94f97951af2712 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst @@ -0,0 +1,4 @@ +Fix :mod:`time` functions returning a negative year or failing to accept +their own output for large timestamps on platforms that support them. Avoid +reporting an incorrect year in errors raised by :mod:`datetime` for those +timestamps. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 92c0d1c9b0f114..06a94f44229fff 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -676,6 +676,18 @@ check_date_args(int year, int month, int day) return 0; } +static int +year_from_tm(const struct tm *tm, int *year) +{ + if (tm->tm_year > INT_MAX - 1900) { + PyErr_Format(PyExc_ValueError, "year must be in %d..%d, not %lld", + MINYEAR, MAXYEAR, (long long)tm->tm_year + 1900); + return -1; + } + *year = tm->tm_year + 1900; + return 0; +} + /* Check that time arguments are in range. Return 0 if they are. If they * aren't, raise ValueError and return -1. */ @@ -3283,14 +3295,18 @@ date_fromtimestamp(PyObject *cls, PyObject *obj) { struct tm tm; time_t t; + int year; if (_PyTime_ObjectToTime_t(obj, &t, _PyTime_ROUND_FLOOR) == -1) return NULL; if (_PyTime_localtime(t, &tm) != 0) return NULL; + if (year_from_tm(&tm, &year) < 0) { + return NULL; + } - return new_date_subclass_ex(tm.tm_year + 1900, + return new_date_subclass_ex(year, tm.tm_mon + 1, tm.tm_mday, cls); @@ -5499,7 +5515,9 @@ datetime_from_timet_and_us(PyObject *cls, TM_FUNC f, time_t timet, int us, if (f(timet, &tm) != 0) return NULL; - year = tm.tm_year + 1900; + if (year_from_tm(&tm, &year) < 0) { + return NULL; + } month = tm.tm_mon + 1; day = tm.tm_mday; hour = tm.tm_hour; diff --git a/Modules/timemodule.c b/Modules/timemodule.c index caf8a4cc680def..c1a9cd28cce7bc 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -477,7 +477,7 @@ tmtotuple(time_module_state *state, struct tm *p #define SET(INDEX, VAL) \ SET_ITEM((INDEX), PyLong_FromLong((long) (VAL))) - SET(0, p->tm_year + 1900); + SET_ITEM(0, PyLong_FromLongLong((long long)p->tm_year + 1900)); SET(1, p->tm_mon + 1); /* Want January == 1 */ SET(2, p->tm_mday); SET(3, p->tm_hour); @@ -611,7 +611,7 @@ static int gettmarg(time_module_state *state, PyObject *args, struct tm *p, const char *format) { - int y; + long long y; memset((void *) p, '\0', sizeof(struct tm)); @@ -627,12 +627,12 @@ gettmarg(time_module_state *state, PyObject *args, &p->tm_wday, &p->tm_yday, &p->tm_isdst)) return 0; - if (y < INT_MIN + 1900) { + if (y < (long long)INT_MIN + 1900 || y > (long long)INT_MAX + 1900) { PyErr_SetString(PyExc_OverflowError, "year out of range"); return 0; } - p->tm_year = y - 1900; + p->tm_year = (int)(y - 1900); p->tm_mon--; p->tm_wday = (p->tm_wday + 1) % 7; p->tm_yday--; @@ -888,7 +888,7 @@ time_strftime(PyObject *module, PyObject *args) return NULL; } else if (!gettmarg(state, tup, &buf, - "iiiiiiiii;strftime(): illegal time tuple argument") || + "Liiiiiiii;strftime(): illegal time tuple argument") || !checktm(&buf)) { return NULL; @@ -900,7 +900,7 @@ time_strftime(PyObject *module, PyObject *args) // physical devices (#123017). #if defined(_MSC_VER) || (defined(__sun) && defined(__SVR4)) || defined(_AIX) \ || defined(__VXWORKS__) || defined(__ANDROID__) - if (buf.tm_year + 1900 < 1 || 9999 < buf.tm_year + 1900) { + if (buf.tm_year < 1 - 1900 || buf.tm_year > 9999 - 1900) { PyErr_SetString(PyExc_ValueError, "strftime() requires year in [1; 9999]"); return NULL; @@ -1023,12 +1023,12 @@ _asctime(struct tm *timeptr) "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; return PyUnicode_FromFormat( - "%s %s%3d %.2d:%.2d:%.2d %d", + "%s %s%3d %.2d:%.2d:%.2d %lld", wday_name[timeptr->tm_wday], mon_name[timeptr->tm_mon], timeptr->tm_mday, timeptr->tm_hour, timeptr->tm_min, timeptr->tm_sec, - 1900 + timeptr->tm_year); + (long long)timeptr->tm_year + 1900); } static PyObject * @@ -1047,7 +1047,7 @@ time_asctime(PyObject *module, PyObject *args) return NULL; } else if (!gettmarg(state, tup, &buf, - "iiiiiiiii;asctime(): illegal time tuple argument") || + "Liiiiiiii;asctime(): illegal time tuple argument") || !checktm(&buf)) { return NULL; @@ -1090,7 +1090,7 @@ time_mktime(PyObject *module, PyObject *tm_tuple) time_module_state *state = get_time_state(module); if (!gettmarg(state, tm_tuple, &tm, - "iiiiiiiii;mktime(): illegal time tuple argument")) + "Liiiiiiii;mktime(): illegal time tuple argument")) { return NULL; }