mirror of https://github.com/python/cpython
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:
parent
68e384c217
commit
b0c6cf5f17
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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);
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue