bpo-15873: Implement [date][time].fromisoformat (#4699)

Closes bpo-15873.
diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
index d0886c4..1d0c1c5 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -49,7 +49,6 @@
 INF = float("inf")
 NAN = float("nan")
 
-
 #############################################################################
 # module tests
 
@@ -1588,6 +1587,63 @@
             # blow up because other fields are insane.
             self.theclass(base[:2] + bytes([ord_byte]) + base[3:])
 
+    def test_fromisoformat(self):
+        # Test that isoformat() is reversible
+        base_dates = [
+            (1, 1, 1),
+            (1000, 2, 14),
+            (1900, 1, 1),
+            (2000, 2, 29),
+            (2004, 11, 12),
+            (2004, 4, 3),
+            (2017, 5, 30)
+        ]
+
+        for dt_tuple in base_dates:
+            dt = self.theclass(*dt_tuple)
+            dt_str = dt.isoformat()
+            with self.subTest(dt_str=dt_str):
+                dt_rt = self.theclass.fromisoformat(dt.isoformat())
+
+                self.assertEqual(dt, dt_rt)
+
+    def test_fromisoformat_subclass(self):
+        class DateSubclass(self.theclass):
+            pass
+
+        dt = DateSubclass(2014, 12, 14)
+
+        dt_rt = DateSubclass.fromisoformat(dt.isoformat())
+
+        self.assertIsInstance(dt_rt, DateSubclass)
+
+    def test_fromisoformat_fails(self):
+        # Test that fromisoformat() fails on invalid values
+        bad_strs = [
+            '',                 # Empty string
+            '009-03-04',        # Not 10 characters
+            '123456789',        # Not a date
+            '200a-12-04',       # Invalid character in year
+            '2009-1a-04',       # Invalid character in month
+            '2009-12-0a',       # Invalid character in day
+            '2009-01-32',       # Invalid day
+            '2009-02-29',       # Invalid leap day
+            '20090228',         # Valid ISO8601 output not from isoformat()
+        ]
+
+        for bad_str in bad_strs:
+            with self.assertRaises(ValueError):
+                self.theclass.fromisoformat(bad_str)
+
+    def test_fromisoformat_fails_typeerror(self):
+        # Test that fromisoformat fails when passed the wrong type
+        import io
+
+        bad_types = [b'2009-03-01', None, io.StringIO('2009-03-01')]
+        for bad_type in bad_types:
+            with self.assertRaises(TypeError):
+                self.theclass.fromisoformat(bad_type)
+
 #############################################################################
 # datetime tests
 
@@ -1675,6 +1731,36 @@
         t = self.theclass(2, 3, 2, tzinfo=tz)
         self.assertEqual(t.isoformat(), "0002-03-02T00:00:00+00:00:16")
 
+    def test_isoformat_timezone(self):
+        tzoffsets = [
+            ('05:00', timedelta(hours=5)),
+            ('02:00', timedelta(hours=2)),
+            ('06:27', timedelta(hours=6, minutes=27)),
+            ('12:32:30', timedelta(hours=12, minutes=32, seconds=30)),
+            ('02:04:09.123456', timedelta(hours=2, minutes=4, seconds=9, microseconds=123456))
+        ]
+
+        tzinfos = [
+            ('', None),
+            ('+00:00', timezone.utc),
+            ('+00:00', timezone(timedelta(0))),
+        ]
+
+        tzinfos += [
+            (prefix + expected, timezone(sign * td))
+            for expected, td in tzoffsets
+            for prefix, sign in [('-', -1), ('+', 1)]
+        ]
+
+        dt_base = self.theclass(2016, 4, 1, 12, 37, 9)
+        exp_base = '2016-04-01T12:37:09'
+
+        for exp_tz, tzi in tzinfos:
+            dt = dt_base.replace(tzinfo=tzi)
+            exp = exp_base + exp_tz
+            with self.subTest(tzi=tzi):
+                assert dt.isoformat() == exp
+
     def test_format(self):
         dt = self.theclass(2007, 9, 10, 4, 5, 1, 123)
         self.assertEqual(dt.__format__(''), str(dt))
@@ -2334,6 +2420,173 @@
         self.assertEqual(dt2.newmeth(-7), dt1.year + dt1.month +
                                           dt1.second - 7)
 
+    def test_fromisoformat_datetime(self):
+        # Test that isoformat() is reversible
+        base_dates = [
+            (1, 1, 1),
+            (1900, 1, 1),
+            (2004, 11, 12),
+            (2017, 5, 30)
+        ]
+
+        base_times = [
+            (0, 0, 0, 0),
+            (0, 0, 0, 241000),
+            (0, 0, 0, 234567),
+            (12, 30, 45, 234567)
+        ]
+
+        separators = [' ', 'T']
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=-5)),
+                   timezone(timedelta(hours=2))]
+
+        dts = [self.theclass(*date_tuple, *time_tuple, tzinfo=tzi)
+               for date_tuple in base_dates
+               for time_tuple in base_times
+               for tzi in tzinfos]
+
+        for dt in dts:
+            for sep in separators:
+                dtstr = dt.isoformat(sep=sep)
+
+                with self.subTest(dtstr=dtstr):
+                    dt_rt = self.theclass.fromisoformat(dtstr)
+                    self.assertEqual(dt, dt_rt)
+
+    def test_fromisoformat_timezone(self):
+        base_dt = self.theclass(2014, 12, 30, 12, 30, 45, 217456)
+
+        tzoffsets = [
+            timedelta(hours=5), timedelta(hours=2),
+            timedelta(hours=6, minutes=27),
+            timedelta(hours=12, minutes=32, seconds=30),
+            timedelta(hours=2, minutes=4, seconds=9, microseconds=123456)
+        ]
+
+        tzoffsets += [-1 * td for td in tzoffsets]
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=0))]
+
+        tzinfos += [timezone(td) for td in tzoffsets]
+
+        for tzi in tzinfos:
+            dt = base_dt.replace(tzinfo=tzi)
+            dtstr = dt.isoformat()
+
+            with self.subTest(tstr=dtstr):
+                dt_rt = self.theclass.fromisoformat(dtstr)
+                assert dt == dt_rt, dt_rt
+
+    def test_fromisoformat_separators(self):
+        separators = [
+            ' ', 'T', '\u007f',     # 1-bit widths
+            '\u0080', 'ʁ',          # 2-bit widths
+            'ᛇ', '時',               # 3-bit widths
+            '🐍'                     # 4-bit widths
+        ]
+
+        for sep in separators:
+            dt = self.theclass(2018, 1, 31, 23, 59, 47, 124789)
+            dtstr = dt.isoformat(sep=sep)
+
+            with self.subTest(dtstr=dtstr):
+                dt_rt = self.theclass.fromisoformat(dtstr)
+                self.assertEqual(dt, dt_rt)
+
+    def test_fromisoformat_ambiguous(self):
+        # Test strings like 2018-01-31+12:15 (where +12:15 is not a time zone)
+        separators = ['+', '-']
+        for sep in separators:
+            dt = self.theclass(2018, 1, 31, 12, 15)
+            dtstr = dt.isoformat(sep=sep)
+
+            with self.subTest(dtstr=dtstr):
+                dt_rt = self.theclass.fromisoformat(dtstr)
+                self.assertEqual(dt, dt_rt)
+
+    def test_fromisoformat_timespecs(self):
+        datetime_bases = [
+            (2009, 12, 4, 8, 17, 45, 123456),
+            (2009, 12, 4, 8, 17, 45, 0)]
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=-5)),
+                   timezone(timedelta(hours=2)),
+                   timezone(timedelta(hours=6, minutes=27))]
+
+        timespecs = ['hours', 'minutes', 'seconds',
+                     'milliseconds', 'microseconds']
+
+        for ip, ts in enumerate(timespecs):
+            for tzi in tzinfos:
+                for dt_tuple in datetime_bases:
+                    if ts == 'milliseconds':
+                        new_microseconds = 1000 * (dt_tuple[6] // 1000)
+                        dt_tuple = dt_tuple[0:6] + (new_microseconds,)
+
+                    dt = self.theclass(*(dt_tuple[0:(4 + ip)]), tzinfo=tzi)
+                    dtstr = dt.isoformat(timespec=ts)
+                    with self.subTest(dtstr=dtstr):
+                        dt_rt = self.theclass.fromisoformat(dtstr)
+                        self.assertEqual(dt, dt_rt)
+
+    def test_fromisoformat_fails_datetime(self):
+        # Test that fromisoformat() fails on invalid values
+        bad_strs = [
+            '',                             # Empty string
+            '2009.04-19T03',                # Wrong first separator
+            '2009-04.19T03',                # Wrong second separator
+            '2009-04-19T0a',                # Invalid hours
+            '2009-04-19T03:1a:45',          # Invalid minutes
+            '2009-04-19T03:15:4a',          # Invalid seconds
+            '2009-04-19T03;15:45',          # Bad first time separator
+            '2009-04-19T03:15;45',          # Bad second time separator
+            '2009-04-19T03:15:4500:00',     # Bad time zone separator
+            '2009-04-19T03:15:45.2345',     # Too many digits for milliseconds
+            '2009-04-19T03:15:45.1234567',  # Too many digits for microseconds
+            '2009-04-19T03:15:45.123456+24:30',    # Invalid time zone offset
+            '2009-04-19T03:15:45.123456-24:30',    # Invalid negative offset
+            '2009-04-10ᛇᛇᛇᛇᛇ12:15',         # Too many unicode separators
+            '2009-04-19T1',                 # Incomplete hours
+            '2009-04-19T12:3',              # Incomplete minutes
+            '2009-04-19T12:30:4',           # Incomplete seconds
+            '2009-04-19T12:',               # Ends with time separator
+            '2009-04-19T12:30:',            # Ends with time separator
+            '2009-04-19T12:30:45.',         # Ends with time separator
+            '2009-04-19T12:30:45.123456+',  # Ends with timzone separator
+            '2009-04-19T12:30:45.123456-',  # Ends with timzone separator
+            '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
+        ]
+
+        for bad_str in bad_strs:
+            with self.subTest(bad_str=bad_str):
+                with self.assertRaises(ValueError):
+                    self.theclass.fromisoformat(bad_str)
+
+    def test_fromisoformat_utc(self):
+        dt_str = '2014-04-19T13:21:13+00:00'
+        dt = self.theclass.fromisoformat(dt_str)
+
+        self.assertIs(dt.tzinfo, timezone.utc)
+
+    def test_fromisoformat_subclass(self):
+        class DateTimeSubclass(self.theclass):
+            pass
+
+        dt = DateTimeSubclass(2014, 12, 14, 9, 30, 45, 457390,
+                              tzinfo=timezone(timedelta(hours=10, minutes=45)))
+
+        dt_rt = DateTimeSubclass.fromisoformat(dt.isoformat())
+
+        self.assertEqual(dt, dt_rt)
+        self.assertIsInstance(dt_rt, DateTimeSubclass)
+
+
 class TestSubclassDateTime(TestDateTime):
     theclass = SubclassDatetime
     # Override tests not designed for subclass
@@ -2517,6 +2770,36 @@
         self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.000000")
         self.assertEqual(t.isoformat(timespec='auto'), "12:34:56")
 
+    def test_isoformat_timezone(self):
+        tzoffsets = [
+            ('05:00', timedelta(hours=5)),
+            ('02:00', timedelta(hours=2)),
+            ('06:27', timedelta(hours=6, minutes=27)),
+            ('12:32:30', timedelta(hours=12, minutes=32, seconds=30)),
+            ('02:04:09.123456', timedelta(hours=2, minutes=4, seconds=9, microseconds=123456))
+        ]
+
+        tzinfos = [
+            ('', None),
+            ('+00:00', timezone.utc),
+            ('+00:00', timezone(timedelta(0))),
+        ]
+
+        tzinfos += [
+            (prefix + expected, timezone(sign * td))
+            for expected, td in tzoffsets
+            for prefix, sign in [('-', -1), ('+', 1)]
+        ]
+
+        t_base = self.theclass(12, 37, 9)
+        exp_base = '12:37:09'
+
+        for exp_tz, tzi in tzinfos:
+            t = t_base.replace(tzinfo=tzi)
+            exp = exp_base + exp_tz
+            with self.subTest(tzi=tzi):
+                assert t.isoformat() == exp
+
     def test_1653736(self):
         # verify it doesn't accept extra keyword arguments
         t = self.theclass(second=1)
@@ -3055,6 +3338,133 @@
         t2 = t2.replace(tzinfo=Varies())
         self.assertTrue(t1 < t2)  # t1's offset counter still going up
 
+    def test_fromisoformat(self):
+        time_examples = [
+            (0, 0, 0, 0),
+            (23, 59, 59, 999999),
+        ]
+
+        hh = (9, 12, 20)
+        mm = (5, 30)
+        ss = (4, 45)
+        usec = (0, 245000, 678901)
+
+        time_examples += list(itertools.product(hh, mm, ss, usec))
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=2)),
+                   timezone(timedelta(hours=6, minutes=27))]
+
+        for ttup in time_examples:
+            for tzi in tzinfos:
+                t = self.theclass(*ttup, tzinfo=tzi)
+                tstr = t.isoformat()
+
+                with self.subTest(tstr=tstr):
+                    t_rt = self.theclass.fromisoformat(tstr)
+                    self.assertEqual(t, t_rt)
+
+    def test_fromisoformat_timezone(self):
+        base_time = self.theclass(12, 30, 45, 217456)
+
+        tzoffsets = [
+            timedelta(hours=5), timedelta(hours=2),
+            timedelta(hours=6, minutes=27),
+            timedelta(hours=12, minutes=32, seconds=30),
+            timedelta(hours=2, minutes=4, seconds=9, microseconds=123456)
+        ]
+
+        tzoffsets += [-1 * td for td in tzoffsets]
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=0))]
+
+        tzinfos += [timezone(td) for td in tzoffsets]
+
+        for tzi in tzinfos:
+            t = base_time.replace(tzinfo=tzi)
+            tstr = t.isoformat()
+
+            with self.subTest(tstr=tstr):
+                t_rt = self.theclass.fromisoformat(tstr)
+                assert t == t_rt, t_rt
+
+    def test_fromisoformat_timespecs(self):
+        time_bases = [
+            (8, 17, 45, 123456),
+            (8, 17, 45, 0)
+        ]
+
+        tzinfos = [None, timezone.utc,
+                   timezone(timedelta(hours=-5)),
+                   timezone(timedelta(hours=2)),
+                   timezone(timedelta(hours=6, minutes=27))]
+
+        timespecs = ['hours', 'minutes', 'seconds',
+                     'milliseconds', 'microseconds']
+
+        for ip, ts in enumerate(timespecs):
+            for tzi in tzinfos:
+                for t_tuple in time_bases:
+                    if ts == 'milliseconds':
+                        new_microseconds = 1000 * (t_tuple[-1] // 1000)
+                        t_tuple = t_tuple[0:-1] + (new_microseconds,)
+
+                    t = self.theclass(*(t_tuple[0:(1 + ip)]), tzinfo=tzi)
+                    tstr = t.isoformat(timespec=ts)
+                    with self.subTest(tstr=tstr):
+                        t_rt = self.theclass.fromisoformat(tstr)
+                        self.assertEqual(t, t_rt)
+
+    def test_fromisoformat_fails(self):
+        bad_strs = [
+            '',                         # Empty string
+            '12:',                      # Ends on a separator
+            '12:30:',                   # Ends on a separator
+            '12:30:15.',                # Ends on a separator
+            '1',                        # Incomplete hours
+            '12:3',                     # Incomplete minutes
+            '12:30:1',                  # Incomplete seconds
+            '1a:30:45.334034',          # Invalid character in hours
+            '12:a0:45.334034',          # Invalid character in minutes
+            '12:30:a5.334034',          # Invalid character in seconds
+            '12:30:45.1234',            # Too many digits for milliseconds
+            '12:30:45.1234567',         # Too many digits for microseconds
+            '12:30:45.123456+24:30',    # Invalid time zone offset
+            '12:30:45.123456-24:30',    # Invalid negative offset
+            '12:30:45',                 # Uses full-width unicode colons
+            '12:30:45․123456',          # Uses \u2024 in place of decimal point
+            '12:30:45a',                # Extra at tend of basic time
+            '12:30:45.123a',            # Extra at end of millisecond time
+            '12:30:45.123456a',         # Extra at end of microsecond time
+            '12:30:45.123456+12:00:30a',    # Extra at end of full time
+        ]
+
+        for bad_str in bad_strs:
+            with self.subTest(bad_str=bad_str):
+                with self.assertRaises(ValueError):
+                    self.theclass.fromisoformat(bad_str)
+
+    def test_fromisoformat_fails_typeerror(self):
+        # Test the fromisoformat fails when passed the wrong type
+        import io
+
+        bad_types = [b'12:30:45', None, io.StringIO('12:30:45')]
+
+        for bad_type in bad_types:
+            with self.assertRaises(TypeError):
+                self.theclass.fromisoformat(bad_type)
+
+    def test_fromisoformat_subclass(self):
+        class TimeSubclass(self.theclass):
+            pass
+
+        tsc = TimeSubclass(12, 14, 45, 203745, tzinfo=timezone.utc)
+        tsc_rt = TimeSubclass.fromisoformat(tsc.isoformat())
+
+        self.assertEqual(tsc, tsc_rt)
+        self.assertIsInstance(tsc_rt, TimeSubclass)
+
     def test_subclass_timetz(self):
 
         class C(self.theclass):