Skip to content

Commit b0c6cf5

Browse files
authored
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)
1 parent 68e384c commit b0c6cf5

File tree

5 files changed

+80
-4
lines changed

5 files changed

+80
-4
lines changed

Lib/_pydatetime.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,17 @@ def _parse_isoformat_time(tstr):
463463

464464
time_comps = _parse_hh_mm_ss_ff(timestr)
465465

466+
hour, minute, second, microsecond = time_comps
467+
became_next_day = False
468+
error_from_components = False
469+
if (hour == 24):
470+
if all(time_comp == 0 for time_comp in time_comps[1:]):
471+
hour = 0
472+
time_comps[0] = hour
473+
became_next_day = True
474+
else:
475+
error_from_components = True
476+
466477
tzi = None
467478
if tz_pos == len_str and tstr[-1] == 'Z':
468479
tzi = timezone.utc
@@ -495,7 +506,7 @@ def _parse_isoformat_time(tstr):
495506

496507
time_comps.append(tzi)
497508

498-
return time_comps
509+
return time_comps, became_next_day, error_from_components
499510

500511
# tuple[int, int, int] -> tuple[int, int, int] version of date.fromisocalendar
501512
def _isoweek_to_gregorian(year, week, day):
@@ -1588,7 +1599,7 @@ def fromisoformat(cls, time_string):
15881599
time_string = time_string.removeprefix('T')
15891600

15901601
try:
1591-
return cls(*_parse_isoformat_time(time_string))
1602+
return cls(*_parse_isoformat_time(time_string)[0])
15921603
except Exception:
15931604
raise ValueError(f'Invalid isoformat string: {time_string!r}')
15941605

@@ -1902,10 +1913,27 @@ def fromisoformat(cls, date_string):
19021913

19031914
if tstr:
19041915
try:
1905-
time_components = _parse_isoformat_time(tstr)
1916+
time_components, became_next_day, error_from_components = _parse_isoformat_time(tstr)
19061917
except ValueError:
19071918
raise ValueError(
19081919
f'Invalid isoformat string: {date_string!r}') from None
1920+
else:
1921+
if error_from_components:
1922+
raise ValueError("minute, second, and microsecond must be 0 when hour is 24")
1923+
1924+
if became_next_day:
1925+
year, month, day = date_components
1926+
# Only wrap day/month when it was previously valid
1927+
if month <= 12 and day <= (days_in_month := _days_in_month(year, month)):
1928+
# Calculate midnight of the next day
1929+
day += 1
1930+
if day > days_in_month:
1931+
day = 1
1932+
month += 1
1933+
if month > 12:
1934+
month = 1
1935+
year += 1
1936+
date_components = [year, month, day]
19091937
else:
19101938
time_components = [0, 0, 0, 0, None]
19111939

Lib/test/datetimetester.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3342,6 +3342,9 @@ def test_fromisoformat_datetime_examples(self):
33423342
('2025-01-02T03:04:05,678+00:00:10',
33433343
self.theclass(2025, 1, 2, 3, 4, 5, 678000,
33443344
tzinfo=timezone(timedelta(seconds=10)))),
3345+
('2025-01-02T24:00:00', self.theclass(2025, 1, 3, 0, 0, 0)),
3346+
('2025-01-31T24:00:00', self.theclass(2025, 2, 1, 0, 0, 0)),
3347+
('2025-12-31T24:00:00', self.theclass(2026, 1, 1, 0, 0, 0))
33453348
]
33463349

33473350
for input_str, expected in examples:
@@ -3378,6 +3381,12 @@ def test_fromisoformat_fails_datetime(self):
33783381
'2009-04-19T12:30:45.123456-05:00a', # Extra text
33793382
'2009-04-19T12:30:45.123-05:00a', # Extra text
33803383
'2009-04-19T12:30:45-05:00a', # Extra text
3384+
'2009-04-19T24:00:00.000001', # Has non-zero microseconds on 24:00
3385+
'2009-04-19T24:00:01.000000', # Has non-zero seconds on 24:00
3386+
'2009-04-19T24:01:00.000000', # Has non-zero minutes on 24:00
3387+
'2009-04-32T24:00:00.000000', # Day is invalid before wrapping due to 24:00
3388+
'2009-13-01T24:00:00.000000', # Month is invalid before wrapping due to 24:00
3389+
'9999-12-31T24:00:00.000000', # Year is invalid after wrapping due to 24:00
33813390
]
33823391

33833392
for bad_str in bad_strs:
@@ -4312,7 +4321,7 @@ def test_fromisoformat_timezone(self):
43124321

43134322
with self.subTest(tstr=tstr):
43144323
t_rt = self.theclass.fromisoformat(tstr)
4315-
assert t == t_rt, t_rt
4324+
assert t == t_rt
43164325

43174326
def test_fromisoformat_timespecs(self):
43184327
time_bases = [

Misc/ACKS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1553,6 +1553,7 @@ Carl Robben
15531553
Ben Roberts
15541554
Mark Roberts
15551555
Andy Robinson
1556+
Izan "TizzySaurus" Robinson
15561557
Jim Robinson
15571558
Yolanda Robla
15581559
Daniel Rocco
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add missing ISO-8601 24:00 alternative to midnight of next day to :meth:`datetime.datetime.fromisoformat` and :meth:`datetime.time.fromisoformat`.
2+
Patch by Izan "TizzySaurus" Robinson ([email protected])

Modules/_datetimemodule.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4997,6 +4997,14 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
49974997
goto invalid_string_error;
49984998
}
49994999

5000+
if (hour == 24) {
5001+
if (minute == 0 && second == 0 && microsecond == 0) {
5002+
hour = 0;
5003+
} else {
5004+
goto invalid_iso_midnight;
5005+
}
5006+
}
5007+
50005008
PyObject *tzinfo = tzinfo_from_isoformat_results(rv, tzoffset,
50015009
tzimicrosecond);
50025010

@@ -5015,6 +5023,10 @@ time_fromisoformat(PyObject *cls, PyObject *tstr) {
50155023
Py_DECREF(tzinfo);
50165024
return t;
50175025

5026+
invalid_iso_midnight:
5027+
PyErr_SetString(PyExc_ValueError, "minute, second, and microsecond must be 0 when hour is 24");
5028+
return NULL;
5029+
50185030
invalid_string_error:
50195031
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", tstr);
50205032
return NULL;
@@ -5861,13 +5873,37 @@ datetime_fromisoformat(PyObject *cls, PyObject *dtstr)
58615873
goto error;
58625874
}
58635875

5876+
if ((hour == 24) && (month <= 12)) {
5877+
int d_in_month = days_in_month(year, month);
5878+
if (day <= d_in_month) {
5879+
if (minute == 0 && second == 0 && microsecond == 0) {
5880+
// Calculate midnight of the next day
5881+
hour = 0;
5882+
day += 1;
5883+
if (day > d_in_month) {
5884+
day = 1;
5885+
month += 1;
5886+
if (month > 12) {
5887+
month = 1;
5888+
year += 1;
5889+
}
5890+
}
5891+
} else {
5892+
goto invalid_iso_midnight;
5893+
}
5894+
}
5895+
}
58645896
PyObject *dt = new_datetime_subclass_ex(year, month, day, hour, minute,
58655897
second, microsecond, tzinfo, cls);
58665898

58675899
Py_DECREF(tzinfo);
58685900
Py_DECREF(dtstr_clean);
58695901
return dt;
58705902

5903+
invalid_iso_midnight:
5904+
PyErr_SetString(PyExc_ValueError, "minute, second, and microsecond must be 0 when hour is 24");
5905+
return NULL;
5906+
58715907
invalid_string_error:
58725908
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %R", dtstr);
58735909

0 commit comments

Comments
 (0)