bpo-15873: Implement [date][time].fromisoformat (#4699)
Closes bpo-15873.
diff --git a/Lib/datetime.py b/Lib/datetime.py
index 67d8600..8fa18a7 100644
--- a/Lib/datetime.py
+++ b/Lib/datetime.py
@@ -173,6 +173,24 @@
else:
return fmt.format(hh, mm, ss, us)
+def _format_offset(off):
+ s = ''
+ if off is not None:
+ if off.days < 0:
+ sign = "-"
+ off = -off
+ else:
+ sign = "+"
+ hh, mm = divmod(off, timedelta(hours=1))
+ mm, ss = divmod(mm, timedelta(minutes=1))
+ s += "%s%02d:%02d" % (sign, hh, mm)
+ if ss or ss.microseconds:
+ s += ":%02d" % ss.seconds
+
+ if ss.microseconds:
+ s += '.%06d' % ss.microseconds
+ return s
+
# Correctly substitute for %z and %Z escapes in strftime formats.
def _wrap_strftime(object, format, timetuple):
# Don't call utcoffset() or tzname() unless actually needed.
@@ -237,6 +255,102 @@
newformat = "".join(newformat)
return _time.strftime(newformat, timetuple)
+# Helpers for parsing the result of isoformat()
+def _parse_isoformat_date(dtstr):
+ # It is assumed that this function will only be called with a
+ # string of length exactly 10, and (though this is not used) ASCII-only
+ year = int(dtstr[0:4])
+ if dtstr[4] != '-':
+ raise ValueError('Invalid date separator: %s' % dtstr[4])
+
+ month = int(dtstr[5:7])
+
+ if dtstr[7] != '-':
+ raise ValueError('Invalid date separator')
+
+ day = int(dtstr[8:10])
+
+ return [year, month, day]
+
+def _parse_hh_mm_ss_ff(tstr):
+ # Parses things of the form HH[:MM[:SS[.fff[fff]]]]
+ len_str = len(tstr)
+
+ time_comps = [0, 0, 0, 0]
+ pos = 0
+ for comp in range(0, 3):
+ if (len_str - pos) < 2:
+ raise ValueError('Incomplete time component')
+
+ time_comps[comp] = int(tstr[pos:pos+2])
+
+ pos += 2
+ next_char = tstr[pos:pos+1]
+
+ if not next_char or comp >= 2:
+ break
+
+ if next_char != ':':
+ raise ValueError('Invalid time separator: %c' % next_char)
+
+ pos += 1
+
+ if pos < len_str:
+ if tstr[pos] != '.':
+ raise ValueError('Invalid microsecond component')
+ else:
+ pos += 1
+
+ len_remainder = len_str - pos
+ if len_remainder not in (3, 6):
+ raise ValueError('Invalid microsecond component')
+
+ time_comps[3] = int(tstr[pos:])
+ if len_remainder == 3:
+ time_comps[3] *= 1000
+
+ return time_comps
+
+def _parse_isoformat_time(tstr):
+ # Format supported is HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]
+ len_str = len(tstr)
+ if len_str < 2:
+ raise ValueError('Isoformat time too short')
+
+ # This is equivalent to re.search('[+-]', tstr), but faster
+ tz_pos = (tstr.find('-') + 1 or tstr.find('+') + 1)
+ timestr = tstr[:tz_pos-1] if tz_pos > 0 else tstr
+
+ time_comps = _parse_hh_mm_ss_ff(timestr)
+
+ tzi = None
+ if tz_pos > 0:
+ tzstr = tstr[tz_pos:]
+
+ # Valid time zone strings are:
+ # HH:MM len: 5
+ # HH:MM:SS len: 8
+ # HH:MM:SS.ffffff len: 15
+
+ if len(tzstr) not in (5, 8, 15):
+ raise ValueError('Malformed time zone string')
+
+ tz_comps = _parse_hh_mm_ss_ff(tzstr)
+ if all(x == 0 for x in tz_comps):
+ tzi = timezone.utc
+ else:
+ tzsign = -1 if tstr[tz_pos - 1] == '-' else 1
+
+ td = timedelta(hours=tz_comps[0], minutes=tz_comps[1],
+ seconds=tz_comps[2], microseconds=tz_comps[3])
+
+ tzi = timezone(tzsign * td)
+
+ time_comps.append(tzi)
+
+ return time_comps
+
+
# Just raise TypeError if the arg isn't None or a string.
def _check_tzname(name):
if name is not None and not isinstance(name, str):
@@ -732,6 +846,19 @@
y, m, d = _ord2ymd(n)
return cls(y, m, d)
+ @classmethod
+ def fromisoformat(cls, date_string):
+ """Construct a date from the output of date.isoformat()."""
+ if not isinstance(date_string, str):
+ raise TypeError('fromisoformat: argument must be str')
+
+ try:
+ assert len(date_string) == 10
+ return cls(*_parse_isoformat_date(date_string))
+ except Exception:
+ raise ValueError('Invalid isoformat string: %s' % date_string)
+
+
# Conversions to string
def __repr__(self):
@@ -1190,22 +1317,10 @@
# Conversion to string
- def _tzstr(self, sep=":"):
- """Return formatted timezone offset (+xx:xx) or None."""
+ def _tzstr(self):
+ """Return formatted timezone offset (+xx:xx) or an empty string."""
off = self.utcoffset()
- if off is not None:
- if off.days < 0:
- sign = "-"
- off = -off
- else:
- sign = "+"
- hh, mm = divmod(off, timedelta(hours=1))
- mm, ss = divmod(mm, timedelta(minutes=1))
- assert 0 <= hh < 24
- off = "%s%02d%s%02d" % (sign, hh, sep, mm)
- if ss:
- off += ':%02d' % ss.seconds
- return off
+ return _format_offset(off)
def __repr__(self):
"""Convert to formal string, for repr()."""
@@ -1244,6 +1359,18 @@
__str__ = isoformat
+ @classmethod
+ def fromisoformat(cls, time_string):
+ """Construct a time from the output of isoformat()."""
+ if not isinstance(time_string, str):
+ raise TypeError('fromisoformat: argument must be str')
+
+ try:
+ return cls(*_parse_isoformat_time(time_string))
+ except Exception:
+ raise ValueError('Invalid isoformat string: %s' % time_string)
+
+
def strftime(self, fmt):
"""Format using strftime(). The date part of the timestamp passed
to underlying strftime should not be used.
@@ -1497,6 +1624,31 @@
time.hour, time.minute, time.second, time.microsecond,
tzinfo, fold=time.fold)
+ @classmethod
+ def fromisoformat(cls, date_string):
+ """Construct a datetime from the output of datetime.isoformat()."""
+ if not isinstance(date_string, str):
+ raise TypeError('fromisoformat: argument must be str')
+
+ # Split this at the separator
+ dstr = date_string[0:10]
+ tstr = date_string[11:]
+
+ try:
+ date_components = _parse_isoformat_date(dstr)
+ except ValueError:
+ raise ValueError('Invalid isoformat string: %s' % date_string)
+
+ if tstr:
+ try:
+ time_components = _parse_isoformat_time(tstr)
+ except ValueError:
+ raise ValueError('Invalid isoformat string: %s' % date_string)
+ else:
+ time_components = [0, 0, 0, 0, None]
+
+ return cls(*(date_components + time_components))
+
def timetuple(self):
"Return local time tuple compatible with time.localtime()."
dst = self.dst()
@@ -1673,18 +1825,10 @@
self._microsecond, timespec))
off = self.utcoffset()
- if off is not None:
- if off.days < 0:
- sign = "-"
- off = -off
- else:
- sign = "+"
- hh, mm = divmod(off, timedelta(hours=1))
- mm, ss = divmod(mm, timedelta(minutes=1))
- s += "%s%02d:%02d" % (sign, hh, mm)
- if ss:
- assert not ss.microseconds
- s += ":%02d" % ss.seconds
+ tz = _format_offset(off)
+ if tz:
+ s += tz
+
return s
def __repr__(self):
@@ -2275,9 +2419,10 @@
_check_date_fields, _check_int_field, _check_time_fields,
_check_tzinfo_arg, _check_tzname, _check_utc_offset, _cmp, _cmperror,
_date_class, _days_before_month, _days_before_year, _days_in_month,
- _format_time, _is_leap, _isoweek1monday, _math, _ord2ymd,
- _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
- _divide_and_round)
+ _format_time, _format_offset, _is_leap, _isoweek1monday, _math,
+ _ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
+ _divide_and_round, _parse_isoformat_date, _parse_isoformat_time,
+ _parse_hh_mm_ss_ff)
# XXX Since import * above excludes names that start with _,
# docstring does not get overwritten. In the future, it may be
# appropriate to maintain a single module level docstring and
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):