gh-102450: Add ISO-8601 alternative for midnight to `fromisoformat()` calls. (#105856)

* Add NEWS.d entry

* Allow ISO-8601 24:00 alternative to midnight on datetime.time.fromisoformat()

* Allow ISO-8601 24:00 alternative to midnight on datetime.datetime.fromisoformat()

* Add NEWS.d entry

* Improve error message when hour is 24 and minute/second/microsecond is not 0

* Add tests for 24:00 fromisoformat

* Remove duplicate call to days_in_month() by storing in variable

* Add Python implementation

* Fix Lint

* Fix differing error msg in datetime.fromisoformat implementations when 24hrs has non-zero time component(s)

* Fix using time components inside tzinfo in Python implementation

* Don't parse tzinfo in C implementation when invalid iso midnight

* Remove duplicated variable in datetime test assertion line

* Add self to acknowledgements

* Remove duplicate NEWS entry

* Linting

* Add missing test case for when wrapping the year makes it invalid (too large)
This commit is contained in:
TizzySaurus 2024-09-25 22:32:51 +01:00 committed by GitHub
parent 68e384c217
commit b0c6cf5f17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 80 additions and 4 deletions

View File

@ -463,6 +463,17 @@ def _parse_isoformat_time(tstr):
time_comps = _parse_hh_mm_ss_ff(timestr) time_comps = _parse_hh_mm_ss_ff(timestr)
hour, minute, second, microsecond = time_comps
became_next_day = False
error_from_components = False
if (hour == 24):
if all(time_comp == 0 for time_comp in time_comps[1:]):
hour = 0
time_comps[0] = hour
became_next_day = True
else:
error_from_components = True
tzi = None tzi = None
if tz_pos == len_str and tstr[-1] == 'Z': if tz_pos == len_str and tstr[-1] == 'Z':
tzi = timezone.utc tzi = timezone.utc
@ -495,7 +506,7 @@ def _parse_isoformat_time(tstr):
time_comps.append(tzi) time_comps.append(tzi)
return time_comps return time_comps, became_next_day, error_from_components
# tuple[int, int, int] -> tuple[int, int, int] version of date.fromisocalendar # tuple[int, int, int] -> tuple[int, int, int] version of date.fromisocalendar
def _isoweek_to_gregorian(year, week, day): def _isoweek_to_gregorian(year, week, day):
@ -1588,7 +1599,7 @@ class time:
time_string = time_string.removeprefix('T') time_string = time_string.removeprefix('T')
try: try:
return cls(*_parse_isoformat_time(time_string)) return cls(*_parse_isoformat_time(time_string)[0])
except Exception: except Exception:
raise ValueError(f'Invalid isoformat string: {time_string!r}') raise ValueError(f'Invalid isoformat string: {time_string!r}')
@ -1902,10 +1913,27 @@ class datetime(date):
if tstr: if tstr:
try: try:
time_components = _parse_isoformat_time(tstr) time_components, became_next_day, error_from_components = _parse_isoformat_time(tstr)
except ValueError: except ValueError:
raise ValueError( raise ValueError(
f'Invalid isoformat string: {date_string!r}') from None f'Invalid isoformat string: {date_string!r}') from None
else:
if error_from_components:
raise ValueError("minute, second, and microsecond must be 0 when hour is 24")
if became_next_day:
year, month, day = date_components
# Only wrap day/month when it was previously valid
if month <= 12 and day <= (days_in_month := _days_in_month(year, month)):
# Calculate midnight of the next day
day += 1
if day > days_in_month:
day = 1
month += 1
if month > 12:
month = 1
year += 1
date_components = [year, month, day]
else: else:
time_components = [0, 0, 0, 0, None] time_components = [0, 0, 0, 0, None]

View File

@ -3342,6 +3342,9 @@ class TestDateTime(TestDate):
('2025-01-02T03:04:05,678+00:00:10', ('2025-01-02T03:04:05,678+00:00:10',
self.theclass(2025, 1, 2, 3, 4, 5, 678000, self.theclass(2025, 1, 2, 3, 4, 5, 678000,
tzinfo=timezone(timedelta(seconds=10)))), tzinfo=timezone(timedelta(seconds=10)))),
('2025-01-02T24:00:00', self.theclass(2025, 1, 3, 0, 0, 0)),
('2025-01-31T24:00:00', self.theclass(2025, 2, 1, 0, 0, 0)),
('2025-12-31T24:00:00', self.theclass(2026, 1, 1, 0, 0, 0))
] ]
for input_str, expected in examples: for input_str, expected in examples:
@ -3378,6 +3381,12 @@ class TestDateTime(TestDate):
'2009-04-19T12:30:45.123456-05:00a', # Extra text '2009-04-19T12:30:45.123456-05:00a', # Extra text
'2009-04-19T12:30:45.123-05:00a', # Extra text '2009-04-19T12:30:45.123-05:00a', # Extra text
'2009-04-19T12:30:45-05:00a', # Extra text '2009-04-19T12:30:45-05:00a', # Extra text
'2009-04-19T24:00:00.000001', # Has non-zero microseconds on 24:00
'2009-04-19T24:00:01.000000', # Has non-zero seconds on 24:00
'2009-04-19T24:01:00.000000', # Has non-zero minutes on 24:00
'2009-04-32T24:00:00.000000', # Day is invalid before wrapping due to 24:00
'2009-13-01T24:00:00.000000', # Month is invalid before wrapping due to 24:00
'9999-12-31T24:00:00.000000', # Year is invalid after wrapping due to 24:00
] ]
for bad_str in bad_strs: for bad_str in bad_strs:
@ -4312,7 +4321,7 @@ class TestTimeTZ(TestTime, TZInfoBase, unittest.TestCase):
with self.subTest(tstr=tstr): with self.subTest(tstr=tstr):
t_rt = self.theclass.fromisoformat(tstr) t_rt = self.theclass.fromisoformat(tstr)
assert t == t_rt, t_rt assert t == t_rt
def test_fromisoformat_timespecs(self): def test_fromisoformat_timespecs(self):
time_bases = [ time_bases = [

View File

@ -1553,6 +1553,7 @@ Carl Robben
Ben Roberts Ben Roberts
Mark Roberts Mark Roberts
Andy Robinson Andy Robinson
Izan "TizzySaurus" Robinson
Jim Robinson Jim Robinson
Yolanda Robla Yolanda Robla
Daniel Rocco Daniel Rocco

View File

@ -0,0 +1,2 @@
Add missing ISO-8601 24:00 alternative to midnight of next day to :meth:`datetime.datetime.fromisoformat` and :meth:`datetime.time.fromisoformat`.
Patch by Izan "TizzySaurus" Robinson (tizzysaurus@gmail.com)

View File

@ -4997,6 +4997,14 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
goto invalid_string_error; goto invalid_string_error;
} }
if (hour == 24) {
if (minute == 0 && second == 0 && microsecond == 0) {
hour = 0;
} else {
goto invalid_iso_midnight;
}
}
PyObject *tzinfo = tzinfo_from_isoformat_results(rv, tzoffset, PyObject *tzinfo = tzinfo_from_isoformat_results(rv, tzoffset,
tzimicrosecond); tzimicrosecond);
@ -5015,6 +5023,10 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
Py_DECREF(tzinfo); Py_DECREF(tzinfo);
return t; return t;
invalid_iso_midnight:
PyErr_SetString(PyExc_ValueError, "minute, second, and microsecond must be 0 when hour is 24");
return NULL;
invalid_string_error: invalid_string_error:
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", tstr); PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", tstr);
return NULL; return NULL;
@ -5861,6 +5873,26 @@ datetime_fromisoformat(PyObject *cls, PyObject *dtstr)
goto error; goto error;
} }
if ((hour == 24) && (month <= 12)) {
int d_in_month = days_in_month(year, month);
if (day <= d_in_month) {
if (minute == 0 && second == 0 && microsecond == 0) {
// Calculate midnight of the next day
hour = 0;
day += 1;
if (day > d_in_month) {
day = 1;
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
} else {
goto invalid_iso_midnight;
}
}
}
PyObject *dt = new_datetime_subclass_ex(year, month, day, hour, minute, PyObject *dt = new_datetime_subclass_ex(year, month, day, hour, minute,
second, microsecond, tzinfo, cls); second, microsecond, tzinfo, cls);
@ -5868,6 +5900,10 @@ datetime_fromisoformat(PyObject *cls, PyObject *dtstr)
Py_DECREF(dtstr_clean); Py_DECREF(dtstr_clean);
return dt; return dt;
invalid_iso_midnight:
PyErr_SetString(PyExc_ValueError, "minute, second, and microsecond must be 0 when hour is 24");
return NULL;
invalid_string_error: invalid_string_error:
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", dtstr); PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", dtstr);