Skip to content

Commit

Permalink
gh-102450: Add ISO-8601 alternative for midnight to fromisoformat()
Browse files Browse the repository at this point in the history
… 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)
  • Loading branch information
TizzySaurus authored Sep 25, 2024
1 parent 68e384c commit b0c6cf5
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 4 deletions.
34 changes: 31 additions & 3 deletions Lib/_pydatetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,17 @@ def _parse_isoformat_time(tstr):

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
if tz_pos == len_str and tstr[-1] == 'Z':
tzi = timezone.utc
Expand Down Expand Up @@ -495,7 +506,7 @@ def _parse_isoformat_time(tstr):

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
def _isoweek_to_gregorian(year, week, day):
Expand Down Expand Up @@ -1588,7 +1599,7 @@ def fromisoformat(cls, time_string):
time_string = time_string.removeprefix('T')

try:
return cls(*_parse_isoformat_time(time_string))
return cls(*_parse_isoformat_time(time_string)[0])
except Exception:
raise ValueError(f'Invalid isoformat string: {time_string!r}')

Expand Down Expand Up @@ -1902,10 +1913,27 @@ def fromisoformat(cls, date_string):

if tstr:
try:
time_components = _parse_isoformat_time(tstr)
time_components, became_next_day, error_from_components = _parse_isoformat_time(tstr)
except ValueError:
raise ValueError(
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:
time_components = [0, 0, 0, 0, None]

Expand Down
11 changes: 10 additions & 1 deletion Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -3342,6 +3342,9 @@ def test_fromisoformat_datetime_examples(self):
('2025-01-02T03:04:05,678+00:00:10',
self.theclass(2025, 1, 2, 3, 4, 5, 678000,
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:
Expand Down Expand Up @@ -3378,6 +3381,12 @@ def test_fromisoformat_fails_datetime(self):
'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-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:
Expand Down Expand Up @@ -4312,7 +4321,7 @@ def test_fromisoformat_timezone(self):

with self.subTest(tstr=tstr):
t_rt = self.theclass.fromisoformat(tstr)
assert t == t_rt, t_rt
assert t == t_rt

def test_fromisoformat_timespecs(self):
time_bases = [
Expand Down
1 change: 1 addition & 0 deletions Misc/ACKS
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,7 @@ Carl Robben
Ben Roberts
Mark Roberts
Andy Robinson
Izan "TizzySaurus" Robinson
Jim Robinson
Yolanda Robla
Daniel Rocco
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 36 additions & 0 deletions Modules/_datetimemodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -4997,6 +4997,14 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
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,
tzimicrosecond);

Expand All @@ -5015,6 +5023,10 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
Py_DECREF(tzinfo);
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:
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", tstr);
return NULL;
Expand Down Expand Up @@ -5861,13 +5873,37 @@ datetime_fromisoformat(PyObject *cls, PyObject *dtstr)
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,
second, microsecond, tzinfo, cls);

Py_DECREF(tzinfo);
Py_DECREF(dtstr_clean);
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:
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", dtstr);

Expand Down

0 comments on commit b0c6cf5

Please sign in to comment.