https://github.com/python/cpython/commit/e1a4c150e7416ec89df0be6526741d3aac93733e
commit: e1a4c150e7416ec89df0be6526741d3aac93733e
branch: main
author: Jelle Zijlstra <[email protected]>
committer: gpshead <[email protected]>
date: 2026-11-08T19:29:31-08:00
summary:
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.
files:
A Misc/NEWS.d/next/Library/2026-10-22-03-55-33.gh-issue-159739.ce26e1.rst
M Lib/test/datetimetester.py
M Lib/test/test_time.py
M Modules/_datetimemodule.c
M Modules/timemodule.c
diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
index 7d242f663824b3..00a9d2bca3f477 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -1590,6 +1590,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 dac4dd9060ccdd..c5754afd0e84a8 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)))
@@ -456,7 +474,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))
@@ -827,7 +845,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 380d8b435e73d6..b9939d5cc71a31 100644
--- a/Modules/_datetimemodule.c
+++ b/Modules/_datetimemodule.c
@@ -668,6 +668,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.
*/
@@ -3297,14 +3309,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);
@@ -5675,7 +5691,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 064ab2cf2364f1..395d6ec0f92d5f 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);
@@ -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;
@@ -1075,12 +1075,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]
@@ -1108,7 +1108,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;
@@ -1164,7 +1164,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;
}
_______________________________________________
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]