Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 19 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e57d1b6
Add NEWS.d entry
TizzySaurus Jun 16, 2023
9613a38
Merge branch 'main' of personal.github.com:TizzySaurus/cpython
TizzySaurus Mar 1, 2024
aa67832
Merge remote-tracking branch 'upstream/main'
TizzySaurus Sep 25, 2024
d711daf
Allow ISO-8601 24:00 alternative to midnight on datetime.time.fromiso…
TizzySaurus Jun 16, 2023
f32aa97
Allow ISO-8601 24:00 alternative to midnight on datetime.datetime.fro…
TizzySaurus Jun 16, 2023
d52bf41
Add NEWS.d entry
TizzySaurus Jun 16, 2023
699dfcb
Improve error message when hour is 24 and minute/second/microsecond i…
TizzySaurus Jun 16, 2023
6e46433
Add tests for 24:00 fromisoformat
TizzySaurus Jun 17, 2023
040da2c
Remove duplicate call to days_in_month() by storing in variable
TizzySaurus Jun 17, 2023
4833d68
Add Python implementation
TizzySaurus Jun 17, 2023
c71764f
Fix Lint
TizzySaurus Jun 17, 2023
0b92a1c
Fix differing error msg in datetime.fromisoformat implementations whe…
TizzySaurus Jun 17, 2023
c9db98b
Fix using time components inside tzinfo in Python implementation
TizzySaurus Jun 18, 2023
004a5ff
Don't parse tzinfo in C implementation when invalid iso midnight
TizzySaurus Jun 18, 2023
f182e2e
Remove duplicated variable in datetime test assertion line
TizzySaurus Jun 18, 2023
1b65671
Add self to acknowledgements
TizzySaurus Sep 25, 2024
ae85372
Remove duplicate NEWS entry
TizzySaurus Sep 25, 2024
9d02f82
Linting
TizzySaurus Sep 25, 2024
2ccb2a2
Add missing test case for when wrapping the year makes it invalid (to…
TizzySaurus Sep 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
TizzySaurus marked this conversation as resolved.
Show resolved Hide resolved
'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
Loading