diff --git a/Lib/_pydatetime.py b/Lib/_pydatetime.py index be90c9b1315d53..600a7e08345a82 100644 --- a/Lib/_pydatetime.py +++ b/Lib/_pydatetime.py @@ -60,14 +60,14 @@ def _days_in_month(year, month): def _days_before_month(year, month): "year, month -> number of days in year preceding first day of month." - assert 1 <= month <= 12, 'month must be in 1..12' + assert 1 <= month <= 12, f"month must be in 1..12, not {month}" return _DAYS_BEFORE_MONTH[month] + (month > 2 and _is_leap(year)) def _ymd2ord(year, month, day): "year, month, day -> ordinal, considering 01-Jan-0001 as day 1." - assert 1 <= month <= 12, 'month must be in 1..12' + assert 1 <= month <= 12, f"month must be in 1..12, not {month}" dim = _days_in_month(year, month) - assert 1 <= day <= dim, ('day must be in 1..%d' % dim) + assert 1 <= day <= dim, f"day must be in 1..{dim}, not {day}" return (_days_before_year(year) + _days_before_month(year, month) + day) @@ -512,7 +512,7 @@ def _parse_isoformat_time(tstr): def _isoweek_to_gregorian(year, week, day): # Year is bounded this way because 9999-12-31 is (9999, 52, 5) if not MINYEAR <= year <= MAXYEAR: - raise ValueError(f"Year is out of range: {year}") + raise ValueError(f"year must be in {MINYEAR}..{MAXYEAR}, not {year}") if not 0 < week < 53: out_of_range = True @@ -545,7 +545,7 @@ def _isoweek_to_gregorian(year, week, day): def _check_tzname(name): if name is not None and not isinstance(name, str): raise TypeError("tzinfo.tzname() must return None or string, " - "not '%s'" % type(name)) + f"not {type(name).__name__!r}") # name is the offset-producing method, "utcoffset" or "dst". # offset is what it returned. @@ -558,24 +558,24 @@ def _check_utc_offset(name, offset): if offset is None: return if not isinstance(offset, timedelta): - raise TypeError("tzinfo.%s() must return None " - "or timedelta, not '%s'" % (name, type(offset))) + raise TypeError(f"tzinfo.{name}() must return None " + f"or timedelta, not {type(offset).__name__!r}") if not -timedelta(1) < offset < timedelta(1): - raise ValueError("%s()=%s, must be strictly between " - "-timedelta(hours=24) and timedelta(hours=24)" % - (name, offset)) + raise ValueError("offset must be a timedelta " + "strictly between -timedelta(hours=24) and " + f"timedelta(hours=24), not {offset!r}") def _check_date_fields(year, month, day): year = _index(year) month = _index(month) day = _index(day) if not MINYEAR <= year <= MAXYEAR: - raise ValueError('year must be in %d..%d' % (MINYEAR, MAXYEAR), year) + raise ValueError(f"year must be in {MINYEAR}..{MAXYEAR}, not {year}") if not 1 <= month <= 12: - raise ValueError('month must be in 1..12', month) + raise ValueError(f"month must be in 1..12, not {month}") dim = _days_in_month(year, month) if not 1 <= day <= dim: - raise ValueError('day must be in 1..%d' % dim, day) + raise ValueError(f"day must be in 1..{dim}, not {day}") return year, month, day def _check_time_fields(hour, minute, second, microsecond, fold): @@ -584,20 +584,23 @@ def _check_time_fields(hour, minute, second, microsecond, fold): second = _index(second) microsecond = _index(microsecond) if not 0 <= hour <= 23: - raise ValueError('hour must be in 0..23', hour) + raise ValueError(f"hour must be in 0..23, not {hour}") if not 0 <= minute <= 59: - raise ValueError('minute must be in 0..59', minute) + raise ValueError(f"minute must be in 0..59, not {minute}") if not 0 <= second <= 59: - raise ValueError('second must be in 0..59', second) + raise ValueError(f"second must be in 0..59, not {second}") if not 0 <= microsecond <= 999999: - raise ValueError('microsecond must be in 0..999999', microsecond) + raise ValueError(f"microsecond must be in 0..999999, not {microsecond}") if fold not in (0, 1): - raise ValueError('fold must be either 0 or 1', fold) + raise ValueError(f"fold must be either 0 or 1, not {fold}") return hour, minute, second, microsecond, fold def _check_tzinfo_arg(tz): if tz is not None and not isinstance(tz, tzinfo): - raise TypeError("tzinfo argument must be None or of a tzinfo subclass") + raise TypeError( + "tzinfo argument must be None or of a tzinfo subclass, " + f"not {type(tz).__name__!r}" + ) def _divide_and_round(a, b): """divide a by b and round result to the nearest integer @@ -2418,7 +2421,7 @@ def __new__(cls, offset, name=_Omitted): if not cls._minoffset <= offset <= cls._maxoffset: raise ValueError("offset must be a timedelta " "strictly between -timedelta(hours=24) and " - "timedelta(hours=24).") + f"timedelta(hours=24), not {offset!r}") return cls._create(offset, name) def __init_subclass__(cls): diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 25a3015c4e19ce..d1ef6339789d20 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1962,6 +1962,23 @@ def test_backdoor_resistance(self): # blow up because other fields are insane. self.theclass(base[:2] + bytes([ord_byte]) + base[3:]) + def test_valuerror_messages(self): + pattern = re.compile( + r"(year|month|day) must be in \d+\.\.\d+, not \d+" + ) + test_cases = [ + (2009, 1, 32), # Day out of range + (2009, 2, 31), # Day out of range + (2009, 13, 1), # Month out of range + (2009, 0, 1), # Month out of range + (10000, 12, 31), # Year out of range + (0, 12, 31), # Year out of range + ] + for case in test_cases: + with self.subTest(case): + with self.assertRaisesRegex(ValueError, pattern): + self.theclass(*case) + def test_fromisoformat(self): # Test that isoformat() is reversible base_dates = [ @@ -3212,6 +3229,24 @@ class DateTimeSubclass(self.theclass): self.assertEqual(res.year, 2013) self.assertEqual(res.fold, fold) + def test_valuerror_messages(self): + pattern = re.compile( + r"(year|month|day|hour|minute|second) must " + r"be in \d+\.\.\d+, not \d+" + ) + test_cases = [ + (2009, 4, 1, 12, 30, 90), # Second out of range + (2009, 4, 1, 12, 90, 45), # Minute out of range + (2009, 4, 1, 25, 30, 45), # Hour out of range + (2009, 4, 32, 24, 0, 0), # Day out of range + (2009, 13, 1, 24, 0, 0), # Month out of range + (9999, 12, 31, 24, 0, 0), # Year out of range + ] + for case in test_cases: + with self.subTest(case): + with self.assertRaisesRegex(ValueError, pattern): + self.theclass(*case) + def test_fromisoformat_datetime(self): # Test that isoformat() is reversible base_dates = [ @@ -3505,6 +3540,25 @@ def test_fromisoformat_fails_datetime(self): with self.assertRaises(ValueError): self.theclass.fromisoformat(bad_str) + def test_fromisoformat_fails_datetime_valueerror(self): + pattern = re.compile( + r"(year|month|day|hour|minute|second) must " + r"be in \d+\.\.\d+, not \d+" + ) + bad_strs = [ + "2009-04-01T12:30:90", # Second out of range + "2009-04-01T12:90:45", # Minute out of range + "2009-04-01T25:30:45", # Hour out of range + "2009-04-32T24:00:00", # Day out of range + "2009-13-01T24:00:00", # Month out of range + "9999-12-31T24:00:00", # Year out of range + ] + + for bad_str in bad_strs: + with self.subTest(bad_str=bad_str): + with self.assertRaisesRegex(ValueError, pattern): + self.theclass.fromisoformat(bad_str) + def test_fromisoformat_fails_surrogate(self): # Test that when fromisoformat() fails with a surrogate character as # the separator, the error message contains the original string @@ -4481,6 +4535,21 @@ def utcoffset(self, t): t2 = t2.replace(tzinfo=Varies()) self.assertTrue(t1 < t2) # t1's offset counter still going up + def test_valuerror_messages(self): + pattern = re.compile( + r"(hour|minute|second|microsecond) must be in \d+\.\.\d+, not \d+" + ) + test_cases = [ + (12, 30, 90, 9999991), # Microsecond out of range + (12, 30, 90, 000000), # Second out of range + (25, 30, 45, 000000), # Hour out of range + (12, 90, 45, 000000), # Minute out of range + ] + for case in test_cases: + with self.subTest(case): + with self.assertRaisesRegex(ValueError, pattern): + self.theclass(*case) + def test_fromisoformat(self): time_examples = [ (0, 0, 0, 0), diff --git a/Misc/NEWS.d/next/Library/2024-11-27-23-29-05.gh-issue-109798.OPj1CT.rst b/Misc/NEWS.d/next/Library/2024-11-27-23-29-05.gh-issue-109798.OPj1CT.rst new file mode 100644 index 00000000000000..89b66d13d38df3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-11-27-23-29-05.gh-issue-109798.OPj1CT.rst @@ -0,0 +1 @@ +Added additional information into error messages in :mod:`datetime`, and made the messages more consistent between the C and Python implementations. Patch by Semyon Moroz. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index bcbf4217d41a9b..8b202cc51788a9 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -637,17 +637,19 @@ check_date_args(int year, int month, int day) { if (year < MINYEAR || year > MAXYEAR) { - PyErr_Format(PyExc_ValueError, "year %i is out of range", year); + PyErr_Format(PyExc_ValueError, + "year must be in %d..%d, not %d", MINYEAR, MAXYEAR, year); return -1; } if (month < 1 || month > 12) { - PyErr_SetString(PyExc_ValueError, - "month must be in 1..12"); + PyErr_Format(PyExc_ValueError, + "month must be in 1..12, not %d", month); return -1; } - if (day < 1 || day > days_in_month(year, month)) { - PyErr_SetString(PyExc_ValueError, - "day is out of range for month"); + int dim = days_in_month(year, month); + if (day < 1 || day > dim) { + PyErr_Format(PyExc_ValueError, + "day must be in 1..%d, not %d", dim, day); return -1; } return 0; @@ -660,28 +662,25 @@ static int check_time_args(int h, int m, int s, int us, int fold) { if (h < 0 || h > 23) { - PyErr_SetString(PyExc_ValueError, - "hour must be in 0..23"); + PyErr_Format(PyExc_ValueError, "hour must be in 0..23, not %i", h); return -1; } if (m < 0 || m > 59) { - PyErr_SetString(PyExc_ValueError, - "minute must be in 0..59"); + PyErr_Format(PyExc_ValueError, "minute must be in 0..59, not %i", m); return -1; } if (s < 0 || s > 59) { - PyErr_SetString(PyExc_ValueError, - "second must be in 0..59"); + PyErr_Format(PyExc_ValueError, "second must be in 0..59, not %i", s); return -1; } if (us < 0 || us > 999999) { - PyErr_SetString(PyExc_ValueError, - "microsecond must be in 0..999999"); + PyErr_Format(PyExc_ValueError, + "microsecond must be in 0..999999, not %i", us); return -1; } if (fold != 0 && fold != 1) { - PyErr_SetString(PyExc_ValueError, - "fold must be either 0 or 1"); + PyErr_Format(PyExc_ValueError, + "fold must be either 0 or 1, not %i", fold); return -1; } return 0; @@ -1435,8 +1434,7 @@ new_timezone(PyObject *offset, PyObject *name) GET_TD_DAYS(offset) < -1 || GET_TD_DAYS(offset) >= 1) { PyErr_Format(PyExc_ValueError, "offset must be a timedelta" " strictly between -timedelta(hours=24) and" - " timedelta(hours=24)," - " not %R.", offset); + " timedelta(hours=24), not %R", offset); return NULL; } @@ -1505,10 +1503,10 @@ call_tzinfo_method(PyObject *tzinfo, const char *name, PyObject *tzinfoarg) GET_TD_SECONDS(offset) == 0 && GET_TD_MICROSECONDS(offset) < 1) || GET_TD_DAYS(offset) < -1 || GET_TD_DAYS(offset) >= 1) { - Py_DECREF(offset); PyErr_Format(PyExc_ValueError, "offset must be a timedelta" " strictly between -timedelta(hours=24) and" - " timedelta(hours=24)."); + " timedelta(hours=24), not %R", offset); + Py_DECREF(offset); return NULL; } } @@ -2261,7 +2259,7 @@ get_float_as_integer_ratio(PyObject *floatobj) if (!PyTuple_Check(ratio)) { PyErr_Format(PyExc_TypeError, "unexpected return type from as_integer_ratio(): " - "expected tuple, got '%.200s'", + "expected tuple, not '%.200s'", Py_TYPE(ratio)->tp_name); Py_DECREF(ratio); return NULL; @@ -3382,7 +3380,8 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) int rv = iso_to_ymd(year, week, day, &year, &month, &day); if (rv == -4) { - PyErr_Format(PyExc_ValueError, "Year is out of range: %d", year); + PyErr_Format(PyExc_ValueError, + "year must be in %d..%d, not %d", MINYEAR, MAXYEAR, year); return NULL; } @@ -3392,7 +3391,7 @@ date_fromisocalendar(PyObject *cls, PyObject *args, PyObject *kw) } if (rv == -3) { - PyErr_Format(PyExc_ValueError, "Invalid day: %d (range is [1, 7])", + PyErr_Format(PyExc_ValueError, "Invalid weekday: %d (range is [1, 7])", day); return NULL; } @@ -4378,8 +4377,7 @@ timezone_fromutc(PyDateTime_TimeZone *self, PyDateTime_DateTime *dt) return NULL; } if (!HASTZINFO(dt) || dt->tzinfo != (PyObject *)self) { - PyErr_SetString(PyExc_ValueError, "fromutc: dt.tzinfo " - "is not self"); + PyErr_SetString(PyExc_ValueError, "fromutc: dt.tzinfo is not self"); return NULL; } @@ -5352,7 +5350,8 @@ utc_to_seconds(int year, int month, int day, /* ymd_to_ord() doesn't support year <= 0 */ if (year < MINYEAR || year > MAXYEAR) { - PyErr_Format(PyExc_ValueError, "year %i is out of range", year); + PyErr_Format(PyExc_ValueError, + "year must be in %d..%d, not %d", MINYEAR, MAXYEAR, year); return -1; }