From 235ac0abe26a78ac9fa409ec37dabf5a3a422a11 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 70e2c1864e0fef..70a464d8c63eb3 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1469,6 +1469,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 985984b55123ce..c4a9683992c08f 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -21,10 +21,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 @@ -153,6 +154,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))) @@ -398,7 +416,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)) @@ -769,7 +787,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 fc07b4b7bc01fb..5c169ef1dd613e 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -679,6 +679,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. */ @@ -3292,14 +3304,18 @@ date_fromtimestamp(PyTypeObject *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); @@ -5620,7 +5636,9 @@ datetime_from_timet_and_us(PyTypeObject *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 0005974b52499c..60eb9ad0fb4006 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; }