From e2152a1176c5d18bd11b21972e888ed625325940 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 21 Oct 2026 20:58:34 -0700 Subject: [PATCH 1/2] 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. --- Lib/test/test_time.py | 15 +++++++++++++++ ...2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst | 3 +++ Modules/timemodule.c | 6 +++--- 3 files changed, 21 insertions(+), 3 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/test_time.py b/Lib/test/test_time.py index 985984b55123ce6..634ceb01c1b9357 100644 --- a/Lib/test/test_time.py +++ b/Lib/test/test_time.py @@ -153,6 +153,21 @@ 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) + def test_conversions(self): self.assertEqual(time.ctime(self.t), time.asctime(time.localtime(self.t))) 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 000000000000000..198c47b630b527c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst @@ -0,0 +1,3 @@ +Fix :func:`time.gmtime`, :func:`time.localtime`, and :func:`time.ctime` +returning a negative year for large timestamps on platforms that support +them. diff --git a/Modules/timemodule.c b/Modules/timemodule.c index e5162fd3b678557..a4a26750cd72ba1 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -487,7 +487,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); @@ -1041,12 +1041,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); } /*[clinic input] From 219bc1019d8b68dfabba4882a9d902869d3c3adf Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Thu, 22 Oct 2026 10:42:42 -0700 Subject: [PATCH 2/2] 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. --- Lib/test/datetimetester.py | 9 ++++++++ Lib/test/test_time.py | 13 ++++++----- ...-10-22-03-55-33.gh-issue-159739.ce26e1.rst | 7 +++--- Modules/_datetimemodule.c | 22 +++++++++++++++++-- Modules/timemodule.c | 14 ++++++------ 5 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index df7d540cb362f75..a00ef2102f32450 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1462,6 +1462,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 634ceb01c1b9357..c4a9683992c08f5 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 @@ -167,6 +168,8 @@ def test_large_timestamp_year(self): 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), @@ -413,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)) @@ -784,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 index 198c47b630b527c..94f97951af2712d 100644 --- 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 @@ -1,3 +1,4 @@ -Fix :func:`time.gmtime`, :func:`time.localtime`, and :func:`time.ctime` -returning a negative year for large timestamps on platforms that support -them. +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 cd2dd94c8164a53..24845e7fc8fe19e 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -682,6 +682,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. */ @@ -3304,14 +3316,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); @@ -5647,7 +5663,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 a4a26750cd72ba1..60cf6e38b8285b0 100644 --- a/Modules/timemodule.c +++ b/Modules/timemodule.c @@ -629,7 +629,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)); @@ -645,12 +645,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--; @@ -906,7 +906,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; @@ -918,7 +918,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; @@ -1074,7 +1074,7 @@ time_asctime_impl(PyObject *module, PyObject *tup) return NULL; } else if (!gettmarg(state, tup, &buf, - "iiiiiiiii;asctime(): illegal time tuple argument") || + "Liiiiiiii;asctime(): illegal time tuple argument") || !checktm(&buf)) { return NULL; @@ -1130,7 +1130,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; }