]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-102450: Add ISO-8601 alternative for midnight to `fromisoformat()` calls. (#105856)
authorTizzySaurus <47674925+TizzySaurus@users.noreply.github.com>
Wed, 25 Sep 2024 21:32:51 +0000 (22:32 +0100)
committerGitHub <noreply@github.com>
Wed, 25 Sep 2024 21:32:51 +0000 (14:32 -0700)
* 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)

Lib/_pydatetime.py
Lib/test/datetimetester.py
Misc/ACKS
Misc/NEWS.d/next/Library/2023-06-16-14-52-00.gh-issue-102450.MfeR6A.rst [new file with mode: 0644]
Modules/_datetimemodule.c

index f8e121eb79a04d33dddb4ab90abc8ee0bd2b590c..154e6ebb9c51311b4936d1b0e081b024f0d72674 100644 (file)
@@ -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
@@ -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):
@@ -1588,7 +1599,7 @@ class time:
         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}')
 
@@ -1902,10 +1913,27 @@ class datetime(date):
 
         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]
 
index aef24e11393f6a354da9f1ea25f73b5de4562caf..16aff186eb69f7b25095dd8a4d1ad0beac420d21 100644 (file)
@@ -3342,6 +3342,9 @@ class TestDateTime(TestDate):
             ('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:
@@ -3378,6 +3381,12 @@ class TestDateTime(TestDate):
             '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:
@@ -4312,7 +4321,7 @@ class TestTimeTZ(TestTime, TZInfoBase, unittest.TestCase):
 
             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 = [
index ef0f403950255b34fa311af59f3eedf7354ff990..b2529601a2f71abcefebab94b8ced1db15e97bc5 100644 (file)
--- a/Misc/ACKS
+++ b/Misc/ACKS
@@ -1553,6 +1553,7 @@ Carl Robben
 Ben Roberts
 Mark Roberts
 Andy Robinson
+Izan "TizzySaurus" Robinson
 Jim Robinson
 Yolanda Robla
 Daniel Rocco
diff --git a/Misc/NEWS.d/next/Library/2023-06-16-14-52-00.gh-issue-102450.MfeR6A.rst b/Misc/NEWS.d/next/Library/2023-06-16-14-52-00.gh-issue-102450.MfeR6A.rst
new file mode 100644 (file)
index 0000000..abfad5f
--- /dev/null
@@ -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)
index 8562e0ca0bbbabfc5ead8fd1bf298b86e0a2f178..58b365334869da9dd03c50fabfbc2ad61b41f042 100644 (file)
@@ -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);
 
@@ -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;
@@ -5861,6 +5873,26 @@ 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);
 
@@ -5868,6 +5900,10 @@ datetime_fromisoformat(PyObject *cls, PyObject *dtstr)
     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);