Skip to content

Commit 09dc2f5

Browse files
pganssleabalkin
authored andcommitted
bpo-15873: Implement [date][time].fromisoformat (#4699)
Closes bpo-15873.
1 parent 507434f commit 09dc2f5

File tree

5 files changed

+989
-32
lines changed

5 files changed

+989
-32
lines changed

Doc/library/datetime.rst

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,21 @@ Other constructors, all class methods:
436436
d``.
437437

438438

439+
.. classmethod:: date.fromisoformat(date_string)
440+
441+
Return a :class:`date` corresponding to a *date_string* in the format emitted
442+
by :meth:`date.isoformat`. Specifically, this function supports strings in
443+
the format(s) ``YYYY-MM-DD``.
444+
445+
.. caution::
446+
447+
This does not support parsing arbitrary ISO 8601 strings - it is only intended
448+
as the inverse operation of :meth:`date.isoformat`.
449+
450+
.. versionadded:: 3.7
451+
452+
453+
439454
Class attributes:
440455

441456
.. attribute:: date.min
@@ -819,6 +834,21 @@ Other constructors, all class methods:
819834
Added the *tzinfo* argument.
820835

821836

837+
.. classmethod:: datetime.fromisoformat(date_string)
838+
839+
Return a :class:`datetime` corresponding to a *date_string* in one of the
840+
formats emitted by :meth:`date.isoformat` and :meth:`datetime.isoformat`.
841+
Specifically, this function supports strings in the format(s)
842+
``YYYY-MM-DD[*HH[:MM[:SS[.mmm[mmm]]]][+HH:MM[:SS[.ffffff]]]]``,
843+
where ``*`` can match any single character.
844+
845+
.. caution::
846+
847+
This does not support parsing arbitrary ISO 8601 strings - it is only intended
848+
as the inverse operation of :meth:`datetime.isoformat`.
849+
850+
.. versionadded:: 3.7
851+
822852
.. classmethod:: datetime.strptime(date_string, format)
823853

824854
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.
14861516
error-prone and has been removed in Python 3.5. See :issue:`13936` for full
14871517
details.
14881518

1519+
1520+
Other constructor:
1521+
1522+
.. classmethod:: time.fromisoformat(time_string)
1523+
1524+
Return a :class:`time` corresponding to a *time_string* in one of the
1525+
formats emitted by :meth:`time.isoformat`. Specifically, this function supports
1526+
strings in the format(s) ``HH[:MM[:SS[.mmm[mmm]]]][+HH:MM[:SS[.ffffff]]]``.
1527+
1528+
.. caution::
1529+
1530+
This does not support parsing arbitrary ISO 8601 strings - it is only intended
1531+
as the inverse operation of :meth:`time.isoformat`.
1532+
1533+
.. versionadded:: 3.7
1534+
1535+
14891536
Instance methods:
14901537

14911538
.. method:: time.replace(hour=self.hour, minute=self.minute, second=self.second, \
@@ -1587,7 +1634,6 @@ Instance methods:
15871634
``self.tzinfo.tzname(None)``, or raises an exception if the latter doesn't
15881635
return ``None`` or a string object.
15891636

1590-
15911637
Example:
15921638

15931639
>>> from datetime import time, tzinfo, timedelta

Lib/datetime.py

Lines changed: 175 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,24 @@ def _format_time(hh, mm, ss, us, timespec='auto'):
173173
else:
174174
return fmt.format(hh, mm, ss, us)
175175

176+
def _format_offset(off):
177+
s = ''
178+
if off is not None:
179+
if off.days < 0:
180+
sign = "-"
181+
off = -off
182+
else:
183+
sign = "+"
184+
hh, mm = divmod(off, timedelta(hours=1))
185+
mm, ss = divmod(mm, timedelta(minutes=1))
186+
s += "%s%02d:%02d" % (sign, hh, mm)
187+
if ss or ss.microseconds:
188+
s += ":%02d" % ss.seconds
189+
190+
if ss.microseconds:
191+
s += '.%06d' % ss.microseconds
192+
return s
193+
176194
# Correctly substitute for %z and %Z escapes in strftime formats.
177195
def _wrap_strftime(object, format, timetuple):
178196
# Don't call utcoffset() or tzname() unless actually needed.
@@ -237,6 +255,102 @@ def _wrap_strftime(object, format, timetuple):
237255
newformat = "".join(newformat)
238256
return _time.strftime(newformat, timetuple)
239257

258+
# Helpers for parsing the result of isoformat()
259+
def _parse_isoformat_date(dtstr):
260+
# It is assumed that this function will only be called with a
261+
# string of length exactly 10, and (though this is not used) ASCII-only
262+
year = int(dtstr[0:4])
263+
if dtstr[4] != '-':
264+
raise ValueError('Invalid date separator: %s' % dtstr[4])
265+
266+
month = int(dtstr[5:7])
267+
268+
if dtstr[7] != '-':
269+
raise ValueError('Invalid date separator')
270+
271+
day = int(dtstr[8:10])
272+
273+
return [year, month, day]
274+
275+
def _parse_hh_mm_ss_ff(tstr):
276+
# Parses things of the form HH[:MM[:SS[.fff[fff]]]]
277+
len_str = len(tstr)
278+
279+
time_comps = [0, 0, 0, 0]
280+
pos = 0
281+
for comp in range(0, 3):
282+
if (len_str - pos) < 2:
283+
raise ValueError('Incomplete time component')
284+
285+
time_comps[comp] = int(tstr[pos:pos+2])
286+
287+
pos += 2
288+
next_char = tstr[pos:pos+1]
289+
290+
if not next_char or comp >= 2:
291+
break
292+
293+
if next_char != ':':
294+
raise ValueError('Invalid time separator: %c' % next_char)
295+
296+
pos += 1
297+
298+
if pos < len_str:
299+
if tstr[pos] != '.':
300+
raise ValueError('Invalid microsecond component')
301+
else:
302+
pos += 1
303+
304+
len_remainder = len_str - pos
305+
if len_remainder not in (3, 6):
306+
raise ValueError('Invalid microsecond component')
307+
308+
time_comps[3] = int(tstr[pos:])
309+
if len_remainder == 3:
310+
time_comps[3] *= 1000
311+
312+
return time_comps
313+
314+
def _parse_isoformat_time(tstr):
315+
# Format supported is HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]
316+
len_str = len(tstr)
317+
if len_str < 2:
318+
raise ValueError('Isoformat time too short')
319+
320+
# This is equivalent to re.search('[+-]', tstr), but faster
321+
tz_pos = (tstr.find('-') + 1 or tstr.find('+') + 1)
322+
timestr = tstr[:tz_pos-1] if tz_pos > 0 else tstr
323+
324+
time_comps = _parse_hh_mm_ss_ff(timestr)
325+
326+
tzi = None
327+
if tz_pos > 0:
328+
tzstr = tstr[tz_pos:]
329+
330+
# Valid time zone strings are:
331+
# HH:MM len: 5
332+
# HH:MM:SS len: 8
333+
# HH:MM:SS.ffffff len: 15
334+
335+
if len(tzstr) not in (5, 8, 15):
336+
raise ValueError('Malformed time zone string')
337+
338+
tz_comps = _parse_hh_mm_ss_ff(tzstr)
339+
if all(x == 0 for x in tz_comps):
340+
tzi = timezone.utc
341+
else:
342+
tzsign = -1 if tstr[tz_pos - 1] == '-' else 1
343+
344+
td = timedelta(hours=tz_comps[0], minutes=tz_comps[1],
345+
seconds=tz_comps[2], microseconds=tz_comps[3])
346+
347+
tzi = timezone(tzsign * td)
348+
349+
time_comps.append(tzi)
350+
351+
return time_comps
352+
353+
240354
# Just raise TypeError if the arg isn't None or a string.
241355
def _check_tzname(name):
242356
if name is not None and not isinstance(name, str):
@@ -732,6 +846,19 @@ def fromordinal(cls, n):
732846
y, m, d = _ord2ymd(n)
733847
return cls(y, m, d)
734848

849+
@classmethod
850+
def fromisoformat(cls, date_string):
851+
"""Construct a date from the output of date.isoformat()."""
852+
if not isinstance(date_string, str):
853+
raise TypeError('fromisoformat: argument must be str')
854+
855+
try:
856+
assert len(date_string) == 10
857+
return cls(*_parse_isoformat_date(date_string))
858+
except Exception:
859+
raise ValueError('Invalid isoformat string: %s' % date_string)
860+
861+
735862
# Conversions to string
736863

737864
def __repr__(self):
@@ -1190,22 +1317,10 @@ def __hash__(self):
11901317

11911318
# Conversion to string
11921319

1193-
def _tzstr(self, sep=":"):
1194-
"""Return formatted timezone offset (+xx:xx) or None."""
1320+
def _tzstr(self):
1321+
"""Return formatted timezone offset (+xx:xx) or an empty string."""
11951322
off = self.utcoffset()
1196-
if off is not None:
1197-
if off.days < 0:
1198-
sign = "-"
1199-
off = -off
1200-
else:
1201-
sign = "+"
1202-
hh, mm = divmod(off, timedelta(hours=1))
1203-
mm, ss = divmod(mm, timedelta(minutes=1))
1204-
assert 0 <= hh < 24
1205-
off = "%s%02d%s%02d" % (sign, hh, sep, mm)
1206-
if ss:
1207-
off += ':%02d' % ss.seconds
1208-
return off
1323+
return _format_offset(off)
12091324

12101325
def __repr__(self):
12111326
"""Convert to formal string, for repr()."""
@@ -1244,6 +1359,18 @@ def isoformat(self, timespec='auto'):
12441359

12451360
__str__ = isoformat
12461361

1362+
@classmethod
1363+
def fromisoformat(cls, time_string):
1364+
"""Construct a time from the output of isoformat()."""
1365+
if not isinstance(time_string, str):
1366+
raise TypeError('fromisoformat: argument must be str')
1367+
1368+
try:
1369+
return cls(*_parse_isoformat_time(time_string))
1370+
except Exception:
1371+
raise ValueError('Invalid isoformat string: %s' % time_string)
1372+
1373+
12471374
def strftime(self, fmt):
12481375
"""Format using strftime(). The date part of the timestamp passed
12491376
to underlying strftime should not be used.
@@ -1497,6 +1624,31 @@ def combine(cls, date, time, tzinfo=True):
14971624
time.hour, time.minute, time.second, time.microsecond,
14981625
tzinfo, fold=time.fold)
14991626

1627+
@classmethod
1628+
def fromisoformat(cls, date_string):
1629+
"""Construct a datetime from the output of datetime.isoformat()."""
1630+
if not isinstance(date_string, str):
1631+
raise TypeError('fromisoformat: argument must be str')
1632+
1633+
# Split this at the separator
1634+
dstr = date_string[0:10]
1635+
tstr = date_string[11:]
1636+
1637+
try:
1638+
date_components = _parse_isoformat_date(dstr)
1639+
except ValueError:
1640+
raise ValueError('Invalid isoformat string: %s' % date_string)
1641+
1642+
if tstr:
1643+
try:
1644+
time_components = _parse_isoformat_time(tstr)
1645+
except ValueError:
1646+
raise ValueError('Invalid isoformat string: %s' % date_string)
1647+
else:
1648+
time_components = [0, 0, 0, 0, None]
1649+
1650+
return cls(*(date_components + time_components))
1651+
15001652
def timetuple(self):
15011653
"Return local time tuple compatible with time.localtime()."
15021654
dst = self.dst()
@@ -1673,18 +1825,10 @@ def isoformat(self, sep='T', timespec='auto'):
16731825
self._microsecond, timespec))
16741826

16751827
off = self.utcoffset()
1676-
if off is not None:
1677-
if off.days < 0:
1678-
sign = "-"
1679-
off = -off
1680-
else:
1681-
sign = "+"
1682-
hh, mm = divmod(off, timedelta(hours=1))
1683-
mm, ss = divmod(mm, timedelta(minutes=1))
1684-
s += "%s%02d:%02d" % (sign, hh, mm)
1685-
if ss:
1686-
assert not ss.microseconds
1687-
s += ":%02d" % ss.seconds
1828+
tz = _format_offset(off)
1829+
if tz:
1830+
s += tz
1831+
16881832
return s
16891833

16901834
def __repr__(self):
@@ -2275,9 +2419,10 @@ def _name_from_offset(delta):
22752419
_check_date_fields, _check_int_field, _check_time_fields,
22762420
_check_tzinfo_arg, _check_tzname, _check_utc_offset, _cmp, _cmperror,
22772421
_date_class, _days_before_month, _days_before_year, _days_in_month,
2278-
_format_time, _is_leap, _isoweek1monday, _math, _ord2ymd,
2279-
_time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
2280-
_divide_and_round)
2422+
_format_time, _format_offset, _is_leap, _isoweek1monday, _math,
2423+
_ord2ymd, _time, _time_class, _tzinfo_class, _wrap_strftime, _ymd2ord,
2424+
_divide_and_round, _parse_isoformat_date, _parse_isoformat_time,
2425+
_parse_hh_mm_ss_ff)
22812426
# XXX Since import * above excludes names that start with _,
22822427
# docstring does not get overwritten. In the future, it may be
22832428
# appropriate to maintain a single module level docstring and

0 commit comments

Comments
 (0)