Commit 09dc2f50 authored by Paul Ganssle's avatar Paul Ganssle Committed by Alexander Belopolsky

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

Closes bpo-15873.
parent 507434fd
...@@ -436,6 +436,21 @@ Other constructors, all class methods: ...@@ -436,6 +436,21 @@ Other constructors, all class methods:
d``. d``.
.. classmethod:: date.fromisoformat(date_string)
Return a :class:`date` corresponding to a *date_string* in the format emitted
by :meth:`date.isoformat`. Specifically, this function supports strings in
the format(s) ``YYYY-MM-DD``.
.. caution::
This does not support parsing arbitrary ISO 8601 strings - it is only intended
as the inverse operation of :meth:`date.isoformat`.
.. versionadded:: 3.7
Class attributes: Class attributes:
.. attribute:: date.min .. attribute:: date.min
...@@ -819,6 +834,21 @@ Other constructors, all class methods: ...@@ -819,6 +834,21 @@ Other constructors, all class methods:
Added the *tzinfo* argument. Added the *tzinfo* argument.
.. classmethod:: datetime.fromisoformat(date_string)
Return a :class:`datetime` corresponding to a *date_string* in one of the
formats emitted by :meth:`date.isoformat` and :meth:`datetime.isoformat`.
Specifically, this function supports strings in the format(s)
``YYYY-MM-DD[*HH[:MM[:SS[.mmm[mmm]]]][+HH:MM[:SS[.ffffff]]]]``,
where ``*`` can match any single character.
.. caution::
This does not support parsing arbitrary ISO 8601 strings - it is only intended
as the inverse operation of :meth:`datetime.isoformat`.
.. versionadded:: 3.7
.. classmethod:: datetime.strptime(date_string, format) .. classmethod:: datetime.strptime(date_string, format)
Return a :class:`.datetime` corresponding to *date_string*, parsed according to Return a :class:`.datetime` corresponding to *date_string*, parsed according to
...@@ -1486,6 +1516,23 @@ In boolean contexts, a :class:`.time` object is always considered to be true. ...@@ -1486,6 +1516,23 @@ In boolean contexts, a :class:`.time` object is always considered to be true.
error-prone and has been removed in Python 3.5. See :issue:`13936` for full error-prone and has been removed in Python 3.5. See :issue:`13936` for full
details. details.
Other constructor:
.. classmethod:: time.fromisoformat(time_string)
Return a :class:`time` corresponding to a *time_string* in one of the
formats emitted by :meth:`time.isoformat`. Specifically, this function supports
strings in the format(s) ``HH[:MM[:SS[.mmm[mmm]]]][+HH:MM[:SS[.ffffff]]]``.
.. caution::
This does not support parsing arbitrary ISO 8601 strings - it is only intended
as the inverse operation of :meth:`time.isoformat`.
.. versionadded:: 3.7
Instance methods: Instance methods:
.. method:: time.replace(hour=self.hour, minute=self.minute, second=self.second, \ .. method:: time.replace(hour=self.hour, minute=self.minute, second=self.second, \
...@@ -1587,7 +1634,6 @@ Instance methods: ...@@ -1587,7 +1634,6 @@ Instance methods:
``self.tzinfo.tzname(None)``, or raises an exception if the latter doesn't ``self.tzinfo.tzname(None)``, or raises an exception if the latter doesn't
return ``None`` or a string object. return ``None`` or a string object.
Example: Example:
>>> from datetime import time, tzinfo, timedelta >>> from datetime import time, tzinfo, timedelta
......
...@@ -173,6 +173,24 @@ def _format_time(hh, mm, ss, us, timespec='auto'): ...@@ -173,6 +173,24 @@ def _format_time(hh, mm, ss, us, timespec='auto'):
else: else:
return fmt.format(hh, mm, ss, us) 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. # Correctly substitute for %z and %Z escapes in strftime formats.
def _wrap_strftime(object, format, timetuple): def _wrap_strftime(object, format, timetuple):
# Don't call utcoffset() or tzname() unless actually needed. # Don't call utcoffset() or tzname() unless actually needed.
...@@ -237,6 +255,102 @@ def _wrap_strftime(object, format, timetuple): ...@@ -237,6 +255,102 @@ def _wrap_strftime(object, format, timetuple):
newformat = "".join(newformat) newformat = "".join(newformat)
return _time.strftime(newformat, timetuple) 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. # Just raise TypeError if the arg isn't None or a string.
def _check_tzname(name): def _check_tzname(name):
if name is not None and not isinstance(name, str): if name is not None and not isinstance(name, str):
...@@ -732,6 +846,19 @@ class date: ...@@ -732,6 +846,19 @@ class date:
y, m, d = _ord2ymd(n) y, m, d = _ord2ymd(n)
return cls(y, m, d) 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 # Conversions to string
def __repr__(self): def __repr__(self):
...@@ -1190,22 +1317,10 @@ class time: ...@@ -1190,22 +1317,10 @@ class time:
# Conversion to string # Conversion to string
def _tzstr(self, sep=":"): def _tzstr(self):
"""Return formatted timezone offset (+xx:xx) or None.""" """Return formatted timezone offset (+xx:xx) or an empty string."""
off = self.utcoffset() off = self.utcoffset()
if off is not None: return _format_offset(off)
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
def __repr__(self): def __repr__(self):
"""Convert to formal string, for repr().""" """Convert to formal string, for repr()."""
...@@ -1244,6 +1359,18 @@ class time: ...@@ -1244,6 +1359,18 @@ class time:
__str__ = isoformat __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): def strftime(self, fmt):
"""Format using strftime(). The date part of the timestamp passed """Format using strftime(). The date part of the timestamp passed
to underlying strftime should not be used. to underlying strftime should not be used.
...@@ -1497,6 +1624,31 @@ class datetime(date): ...@@ -1497,6 +1624,31 @@ class datetime(date):
time.hour, time.minute, time.second, time.microsecond, time.hour, time.minute, time.second, time.microsecond,
tzinfo, fold=time.fold) 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): def timetuple(self):
"Return local time tuple compatible with time.localtime()." "Return local time tuple compatible with time.localtime()."
dst = self.dst() dst = self.dst()
...@@ -1673,18 +1825,10 @@ class datetime(date): ...@@ -1673,18 +1825,10 @@ class datetime(date):
self._microsecond, timespec)) self._microsecond, timespec))
off = self.utcoffset() off = self.utcoffset()
if off is not None: tz = _format_offset(off)
if off.days < 0: if tz:
sign = "-" s += tz
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
return s return s
def __repr__(self): def __repr__(self):
...@@ -2275,9 +2419,10 @@ else: ...@@ -2275,9 +2419,10 @@ else:
_check_date_fields, _check_int_field, _check_time_fields, _check_date_fields, _check_int_field, _check_time_fields,
_check_tzinfo_arg, _check_tzname, _check_utc_offset, _cmp, _cmperror, _check_tzinfo_arg, _check_tzname, _check_utc_offset, _cmp, _cmperror,
_date_class, _days_before_month, _days_before_year, _days_in_month, _date_class, _days_before_month, _days_before_year, _days_in_month,
_format_time, _is_leap, _isoweek1monday, _math, _ord2ymd, _format_time, _format_offset, _is_leap, _isoweek1monday, _math,
_time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord, _ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
_divide_and_round) _divide_and_round, _parse_isoformat_date, _parse_isoformat_time,
_parse_hh_mm_ss_ff)
# XXX Since import * above excludes names that start with _, # XXX Since import * above excludes names that start with _,
# docstring does not get overwritten. In the future, it may be # docstring does not get overwritten. In the future, it may be
# appropriate to maintain a single module level docstring and # appropriate to maintain a single module level docstring and
......
...@@ -49,7 +49,6 @@ OTHERSTUFF = (10, 34.5, "abc", {}, [], ()) ...@@ -49,7 +49,6 @@ OTHERSTUFF = (10, 34.5, "abc", {}, [], ())
INF = float("inf") INF = float("inf")
NAN = float("nan") NAN = float("nan")
############################################################################# #############################################################################
# module tests # module tests
...@@ -1588,6 +1587,63 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase): ...@@ -1588,6 +1587,63 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase):
# blow up because other fields are insane. # blow up because other fields are insane.
self.theclass(base[:2] + bytes([ord_byte]) + base[3:]) 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 # datetime tests
...@@ -1675,6 +1731,36 @@ class TestDateTime(TestDate): ...@@ -1675,6 +1731,36 @@ class TestDateTime(TestDate):
t = self.theclass(2, 3, 2, tzinfo=tz) t = self.theclass(2, 3, 2, tzinfo=tz)
self.assertEqual(t.isoformat(), "0002-03-02T00:00:00+00:00:16") 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): def test_format(self):
dt = self.theclass(2007, 9, 10, 4, 5, 1, 123) dt = self.theclass(2007, 9, 10, 4, 5, 1, 123)
self.assertEqual(dt.__format__(''), str(dt)) self.assertEqual(dt.__format__(''), str(dt))
...@@ -2334,6 +2420,173 @@ class TestDateTime(TestDate): ...@@ -2334,6 +2420,173 @@ class TestDateTime(TestDate):
self.assertEqual(dt2.newmeth(-7), dt1.year + dt1.month + self.assertEqual(dt2.newmeth(-7), dt1.year + dt1.month +
dt1.second - 7) 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): class TestSubclassDateTime(TestDateTime):
theclass = SubclassDatetime theclass = SubclassDatetime
# Override tests not designed for subclass # Override tests not designed for subclass
...@@ -2517,6 +2770,36 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase): ...@@ -2517,6 +2770,36 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase):
self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.000000") self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.000000")
self.assertEqual(t.isoformat(timespec='auto'), "12:34:56") 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): def test_1653736(self):
# verify it doesn't accept extra keyword arguments # verify it doesn't accept extra keyword arguments
t = self.theclass(second=1) t = self.theclass(second=1)
...@@ -3055,6 +3338,133 @@ class TestTimeTZ(TestTime, TZInfoBase, unittest.TestCase): ...@@ -3055,6 +3338,133 @@ class TestTimeTZ(TestTime, TZInfoBase, unittest.TestCase):
t2 = t2.replace(tzinfo=Varies()) t2 = t2.replace(tzinfo=Varies())
self.assertTrue(t1 < t2) # t1's offset counter still going up 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): def test_subclass_timetz(self):
class C(self.theclass): class C(self.theclass):
......
Added new alternate constructors :meth:`datetime.datetime.fromisoformat`,
:meth:`datetime.time.fromisoformat` and :meth:`datetime.date.fromisoformat`
as the inverse operation of each classes's respective ``isoformat`` methods.
...@@ -663,6 +663,167 @@ set_date_fields(PyDateTime_Date *self, int y, int m, int d) ...@@ -663,6 +663,167 @@ set_date_fields(PyDateTime_Date *self, int y, int m, int d)
SET_DAY(self, d); SET_DAY(self, d);
} }
/* ---------------------------------------------------------------------------
* String parsing utilities and helper functions
*/
static const char*
parse_digits(const char* ptr, int* var, size_t num_digits)
{
for (size_t i = 0; i < num_digits; ++i) {
unsigned int tmp = (unsigned int)(*(ptr++) - '0');
if (tmp > 9) {
return NULL;
}
*var *= 10;
*var += (signed int)tmp;
}
return ptr;
}
static int parse_isoformat_date(const char *dtstr,
int* year, int *month, int* day) {
/* Parse the date components of the result of date.isoformat()
*
* Return codes:
* 0: Success
* -1: Failed to parse date component
* -2: Failed to parse dateseparator
*/
const char *p = dtstr;
p = parse_digits(p, year, 4);
if (NULL == p) {
return -1;
}
if (*(p++) != '-') {
return -2;
}
p = parse_digits(p, month, 2);
if (NULL == p) {
return -1;
}
if (*(p++) != '-') {
return -2;
}
p = parse_digits(p, day, 2);
if (p == NULL) {
return -1;
}
return 0;
}
static int
parse_hh_mm_ss_ff(const char *tstr, const char *tstr_end,
int* hour, int* minute, int *second, int *microsecond) {
const char *p = tstr;
const char *p_end = tstr_end;
int *vals[3] = {hour, minute, second};
// Parse [HH[:MM[:SS]]]
for (size_t i = 0; i < 3; ++i) {
p = parse_digits(p, vals[i], 2);
if (NULL == p) {
return -3;
}
char c = *(p++);
if (p >= p_end) {
return c != '\0';
} else if (c == ':') {
continue;
} else if (c == '.') {
break;
} else {
return -4; // Malformed time separator
}
}
// Parse .fff[fff]
size_t len_remains = p_end - p;
if (!(len_remains == 6 || len_remains == 3)) {
return -3;
}
p = parse_digits(p, microsecond, len_remains);
if (NULL == p) {
return -3;
}
if (len_remains == 3) {
*microsecond *= 1000;
}
// Return 1 if it's not the end of the string
return *p != '\0';
}
static int
parse_isoformat_time(const char *dtstr, size_t dtlen,
int* hour, int *minute, int *second, int *microsecond,
int* tzoffset, int *tzmicrosecond) {
// Parse the time portion of a datetime.isoformat() string
//
// Return codes:
// 0: Success (no tzoffset)
// 1: Success (with tzoffset)
// -3: Failed to parse time component
// -4: Failed to parse time separator
// -5: Malformed timezone string
const char *p = dtstr;
const char *p_end = dtstr + dtlen;
const char *tzinfo_pos = p;
do {
if (*tzinfo_pos == '+' || *tzinfo_pos == '-') {
break;
}
} while(++tzinfo_pos < p_end);
int rv = parse_hh_mm_ss_ff(dtstr, tzinfo_pos,
hour, minute, second, microsecond);
if (rv < 0) {
return rv;
} else if (tzinfo_pos == p_end) {
// We know that there's no time zone, so if there's stuff at the
// end of the string it's an error.
if (rv == 1) {
return -5;
} else {
return 0;
}
}
// Parse time zone component
// Valid formats are:
// - +HH:MM (len 6)
// - +HH:MM:SS (len 9)
// - +HH:MM:SS.ffffff (len 16)
size_t tzlen = p_end - tzinfo_pos;
if (!(tzlen == 6 || tzlen == 9 || tzlen == 16)) {
return -5;
}
int tzsign = (*tzinfo_pos == '-')?-1:1;
tzinfo_pos++;
int tzhour = 0, tzminute = 0, tzsecond = 0;
rv = parse_hh_mm_ss_ff(tzinfo_pos, p_end,
&tzhour, &tzminute, &tzsecond, tzmicrosecond);
*tzoffset = tzsign * ((tzhour * 3600) + (tzminute * 60) + tzsecond);
*tzmicrosecond *= tzsign;
return rv?-5:1;
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* Create various objects, mostly without range checking. * Create various objects, mostly without range checking.
*/ */
...@@ -1063,6 +1224,27 @@ append_keyword_fold(PyObject *repr, int fold) ...@@ -1063,6 +1224,27 @@ append_keyword_fold(PyObject *repr, int fold)
return repr; return repr;
} }
static inline PyObject *
tzinfo_from_isoformat_results(int rv, int tzoffset, int tz_useconds) {
PyObject *tzinfo;
if (rv == 1) {
// Create a timezone from offset in seconds (0 returns UTC)
if (tzoffset == 0) {
Py_INCREF(PyDateTime_TimeZone_UTC);
return PyDateTime_TimeZone_UTC;
}
PyObject *delta = new_delta(0, tzoffset, tz_useconds, 1);
tzinfo = new_timezone(delta, NULL);
Py_XDECREF(delta);
} else {
tzinfo = Py_None;
Py_INCREF(Py_None);
}
return tzinfo;
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* String format helpers. * String format helpers.
*/ */
...@@ -2607,6 +2789,7 @@ date_fromtimestamp(PyObject *cls, PyObject *args) ...@@ -2607,6 +2789,7 @@ date_fromtimestamp(PyObject *cls, PyObject *args)
return result; return result;
} }
/* Return new date from proleptic Gregorian ordinal. Raises ValueError if /* Return new date from proleptic Gregorian ordinal. Raises ValueError if
* the ordinal is out of range. * the ordinal is out of range.
*/ */
...@@ -2633,6 +2816,46 @@ date_fromordinal(PyObject *cls, PyObject *args) ...@@ -2633,6 +2816,46 @@ date_fromordinal(PyObject *cls, PyObject *args)
return result; return result;
} }
/* Return the new date from a string as generated by date.isoformat() */
static PyObject *
date_fromisoformat(PyObject *cls, PyObject *dtstr) {
assert(dtstr != NULL);
if (!PyUnicode_Check(dtstr)) {
PyErr_SetString(PyExc_TypeError, "fromisoformat: argument must be str");
return NULL;
}
Py_ssize_t len;
const char * dt_ptr = PyUnicode_AsUTF8AndSize(dtstr, &len);
int year = 0, month = 0, day = 0;
int rv;
if (len == 10) {
rv = parse_isoformat_date(dt_ptr, &year, &month, &day);
} else {
rv = -1;
}
if (rv < 0) {
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %s",
dt_ptr);
return NULL;
}
PyObject *result;
if ( (PyTypeObject*)cls == &PyDateTime_DateType ) {
result = new_date_ex(year, month, day, (PyTypeObject*)cls);
} else {
result = PyObject_CallFunction(cls, "iii", year, month, day);
}
return result;
}
/* /*
* Date arithmetic. * Date arithmetic.
*/ */
...@@ -2925,6 +3148,10 @@ static PyMethodDef date_methods[] = { ...@@ -2925,6 +3148,10 @@ static PyMethodDef date_methods[] = {
PyDoc_STR("int -> date corresponding to a proleptic Gregorian " PyDoc_STR("int -> date corresponding to a proleptic Gregorian "
"ordinal.")}, "ordinal.")},
{"fromisoformat", (PyCFunction)date_fromisoformat, METH_O |
METH_CLASS,
PyDoc_STR("str -> Construct a date from the output of date.isoformat()")},
{"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS, {"today", (PyCFunction)date_today, METH_NOARGS | METH_CLASS,
PyDoc_STR("Current date or datetime: same as " PyDoc_STR("Current date or datetime: same as "
"self.__class__.fromtimestamp(time.time()).")}, "self.__class__.fromtimestamp(time.time()).")},
...@@ -3972,6 +4199,49 @@ time_replace(PyDateTime_Time *self, PyObject *args, PyObject *kw) ...@@ -3972,6 +4199,49 @@ time_replace(PyDateTime_Time *self, PyObject *args, PyObject *kw)
return clone; return clone;
} }
static PyObject *
time_fromisoformat(PyObject *cls, PyObject *tstr) {
assert(tstr != NULL);
if (!PyUnicode_Check(tstr)) {
PyErr_SetString(PyExc_TypeError, "fromisoformat: argument must be str");
return NULL;
}
Py_ssize_t len;
const char *p = PyUnicode_AsUTF8AndSize(tstr, &len);
int hour = 0, minute = 0, second = 0, microsecond = 0;
int tzoffset, tzimicrosecond = 0;
int rv = parse_isoformat_time(p, len,
&hour, &minute, &second, &microsecond,
&tzoffset, &tzimicrosecond);
if (rv < 0) {
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %s", p);
return NULL;
}
PyObject *tzinfo = tzinfo_from_isoformat_results(rv, tzoffset,
tzimicrosecond);
if (tzinfo == NULL) {
return NULL;
}
PyObject *t;
if ( (PyTypeObject *)cls == &PyDateTime_TimeType ) {
t = new_time(hour, minute, second, microsecond, tzinfo, 0);
} else {
t = PyObject_CallFunction(cls, "iiiiO",
hour, minute, second, microsecond, tzinfo);
}
Py_DECREF(tzinfo);
return t;
}
/* Pickle support, a simple use of __reduce__. */ /* Pickle support, a simple use of __reduce__. */
/* Let basestate be the non-tzinfo data string. /* Let basestate be the non-tzinfo data string.
...@@ -4041,6 +4311,9 @@ static PyMethodDef time_methods[] = { ...@@ -4041,6 +4311,9 @@ static PyMethodDef time_methods[] = {
{"replace", (PyCFunction)time_replace, METH_VARARGS | METH_KEYWORDS, {"replace", (PyCFunction)time_replace, METH_VARARGS | METH_KEYWORDS,
PyDoc_STR("Return time with new specified fields.")}, PyDoc_STR("Return time with new specified fields.")},
{"fromisoformat", (PyCFunction)time_fromisoformat, METH_O | METH_CLASS,
PyDoc_STR("string -> time from time.isoformat() output")},
{"__reduce_ex__", (PyCFunction)time_reduce_ex, METH_VARARGS, {"__reduce_ex__", (PyCFunction)time_reduce_ex, METH_VARARGS,
PyDoc_STR("__reduce_ex__(proto) -> (cls, state)")}, PyDoc_STR("__reduce_ex__(proto) -> (cls, state)")},
...@@ -4506,6 +4779,82 @@ datetime_combine(PyObject *cls, PyObject *args, PyObject *kw) ...@@ -4506,6 +4779,82 @@ datetime_combine(PyObject *cls, PyObject *args, PyObject *kw)
return result; return result;
} }
static PyObject *
datetime_fromisoformat(PyObject* cls, PyObject *dtstr) {
assert(dtstr != NULL);
if (!PyUnicode_Check(dtstr)) {
PyErr_SetString(PyExc_TypeError, "fromisoformat: argument must be str");
return NULL;
}
Py_ssize_t len;
const char * dt_ptr = PyUnicode_AsUTF8AndSize(dtstr, &len);
const char * p = dt_ptr;
int year = 0, month = 0, day = 0;
int hour = 0, minute = 0, second = 0, microsecond = 0;
int tzoffset = 0, tzusec = 0;
// date has a fixed length of 10
int rv = parse_isoformat_date(p, &year, &month, &day);
if (!rv && len > 10) {
// In UTF-8, the length of multi-byte characters is encoded in the MSB
if ((p[10] & 0x80) == 0) {
p += 11;
} else {
switch(p[10] & 0xf0) {
case 0xe0:
p += 13;
break;
case 0xf0:
p += 14;
break;
default:
p += 12;
break;
}
}
len -= (p - dt_ptr);
rv = parse_isoformat_time(p, len,
&hour, &minute, &second, &microsecond,
&tzoffset, &tzusec);
}
if (rv < 0) {
PyErr_Format(PyExc_ValueError, "Invalid isoformat string: %s", dt_ptr);
return NULL;
}
PyObject* tzinfo = tzinfo_from_isoformat_results(rv, tzoffset, tzusec);
if (tzinfo == NULL) {
return NULL;
}
PyObject* dt;
if ( (PyTypeObject*)cls == &PyDateTime_DateTimeType ) {
// Use the fast path constructor
dt = new_datetime(year, month, day, hour, minute, second, microsecond,
tzinfo, 0);
} else {
// Subclass
dt = PyObject_CallFunction(cls, "iiiiiiiO",
year,
month,
day,
hour,
minute,
second,
microsecond,
tzinfo);
}
Py_DECREF(tzinfo);
return dt;
}
/* /*
* Destructor. * Destructor.
*/ */
...@@ -5519,6 +5868,10 @@ static PyMethodDef datetime_methods[] = { ...@@ -5519,6 +5868,10 @@ static PyMethodDef datetime_methods[] = {
METH_VARARGS | METH_KEYWORDS | METH_CLASS, METH_VARARGS | METH_KEYWORDS | METH_CLASS,
PyDoc_STR("date, time -> datetime with same date and time fields")}, PyDoc_STR("date, time -> datetime with same date and time fields")},
{"fromisoformat", (PyCFunction)datetime_fromisoformat,
METH_O | METH_CLASS,
PyDoc_STR("string -> datetime from datetime.isoformat() output")},
/* Instance methods: */ /* Instance methods: */
{"date", (PyCFunction)datetime_getdate, METH_NOARGS, {"date", (PyCFunction)datetime_getdate, METH_NOARGS,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment