diff --git a/Lib/datetime.py b/Lib/datetime.py index 9f942a2..c02cb06 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -6,6 +6,7 @@ time zone and DST data sources. import time as _time import math as _math +import re def _cmp(x, y): return 0 if x == y else 1 if x > y else -1 @@ -331,6 +332,30 @@ def _divide_and_round(a, b): return q +def _parse_isotime(cls, isostring): + match = cls._isore.match(isostring) + if not match: + raise ValueError("invalid RFC 3339 %s string: %r" % (cls.__name__, isostring)) + kw = match.groupdict() + tzinfo = kw.pop('tzinfo', None) + if tzinfo == 'Z' or tzinfo == 'z': + tzinfo = timezone.utc + elif tzinfo is not None: + offset_hours, _, offset_mins = tzinfo[1:].partition(':') + offset = timedelta(hours=int(offset_hours), minutes=int(offset_mins)) + if tzinfo[0] == '-': + offset = -offset + tzinfo = timezone(offset) + us = kw.pop('microsecond', None) + kw = {k: int(v) for k, v in kw.items() if v is not None} + if us: + us = round(float(us), 6) + kw['microsecond'] = int(us * 1e6) + if tzinfo: + kw['tzinfo'] = tzinfo + return cls(**kw) + + class timedelta: """Represent the difference between two datetime objects. @@ -653,6 +678,8 @@ timedelta.max = timedelta(days=999999999, hours=23, minutes=59, seconds=59, microseconds=999999) timedelta.resolution = timedelta(microseconds=1) +_DATE_RE = re.compile(r'(?P\d{4})-(?P\d{2})-(?P\d{2})\$', re.ASCII) + class date: """Concrete date type. @@ -683,6 +710,8 @@ class date: """ __slots__ = '_year', '_month', '_day', '_hashcode' + _isore = _DATE_RE + def __new__(cls, year, month=None, day=None): """Constructor. @@ -729,6 +758,14 @@ class date: y, m, d = _ord2ymd(n) return cls(y, m, d) + @classmethod + def fromisoformat(cls, date_string): + """Constructs a date from an RFC 3339 string, a strict subset of ISO 8601 + + Raises ValueError in case of ill-formatted or invalid string. + """ + return _parse_isotime(cls, date_string) + # Conversions to string def __repr__(self): @@ -1018,6 +1055,10 @@ class tzinfo: _tzinfo_class = tzinfo +_TIME_RE = re.compile(r'(?P\d{2}):(?P\d{2}):(?P\d{2})' + r'(?P\.\d*)?(?PZ|([+-]\d{2}:\d{2}))?\$', + re.ASCII|re.IGNORECASE) + class time: """Time with time zone. @@ -1043,6 +1084,8 @@ class time: """ __slots__ = '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode', '_fold' + _isore = _TIME_RE + def __new__(cls, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0): """Constructor. @@ -1072,6 +1115,15 @@ class time: self._fold = fold return self + @classmethod + def fromisoformat(cls, time_string): + """Constructs a time from an RFC 3339 string, a strict subset of ISO 8601 + + Microseconds are rounded to 6 digits. + Raises ValueError in case of ill-formatted or invalid string. + """ + return _parse_isotime(cls, time_string) + # Read-only field accessors @property def hour(self): @@ -1360,6 +1412,9 @@ class datetime(date): """ __slots__ = date.__slots__ + time.__slots__ + _isore = re.compile(_DATE_RE.pattern[:-1] + r'[T ]' + _TIME_RE.pattern, + re.ASCII|re.IGNORECASE) + def __new__(cls, year, month=None, day=None, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0): if isinstance(year, bytes) and len(year) == 10 and 1 <= year[2]&0x7F <= 12: @@ -1491,6 +1546,15 @@ class datetime(date): time.hour, time.minute, time.second, time.microsecond, tzinfo, fold=time.fold) + @classmethod + def fromisoformat(cls, date_string): + """Constructs a datetime from an RFC 3339 string, a strict subset of ISO 8601 + + Microseconds are rounded to 6 digits. + Raises ValueError if string is ill formatted or invalid + """ + return _parse_isotime(cls, date_string) + def timetuple(self): "Return local time tuple compatible with time.localtime()." dst = self.dst() @@ -2055,6 +2119,9 @@ timezone.min = timezone._create(timezone._minoffset) timezone.max = timezone._create(timezone._maxoffset) _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +del _DATE_RE +del _TIME_RE + # Some time zone algebra. For a datetime x, let # x.n = x stripped of its timezone -- its naive time. # x.o = x.utcoffset(), and assuming that doesn't raise an exception or @@ -2252,6 +2319,7 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) # pretty bizarre, and a tzinfo subclass can override fromutc() if it is. try: + raise ImportError from _datetime import * except ImportError: pass diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index e71f3aa..016dda5 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1105,6 +1105,19 @@ class TestDate(HarmlessMixedComparison, unittest.TestCase): self.assertEqual(d.month, month) self.assertEqual(d.day, day) + def test_fromisoformat(self): + self.assertEqual(self.theclass.fromisoformat('2014-12-31'), + self.theclass(2014, 12, 31)) + self.assertEqual(self.theclass.fromisoformat('4095-07-31'), + self.theclass(4095, 7, 31)) + + with self.assertRaises(ValueError): + self.theclass.fromisoformat('2014-12-011') + with self.assertRaises(ValueError): + self.theclass.fromisoformat('20141211') + with self.assertRaises(ValueError): + self.theclass.fromisoformat('043-12-01') + def test_insane_fromtimestamp(self): # It's possible that some platform maps time_t to double, # and that this test will fail there. This test should @@ -1901,6 +1914,18 @@ class TestDateTime(TestDate): got = self.theclass.utcfromtimestamp(ts) self.verify_field_equality(expected, got) + def test_fromisoformat(self): + self.assertEqual(self.theclass.fromisoformat('2015-12-31T14:27:00'), + self.theclass(2015, 12, 31, 14, 27, 0)) + self.assertEqual(self.theclass.fromisoformat('2015-12-31 14:27:00'), + self.theclass(2015, 12, 31, 14, 27, 0)) + # lowercase 'T' date-time separator. Uncommon but tolerated (rfc 3339) + self.assertEqual(self.theclass.fromisoformat('2015-12-31t14:27:00'), + self.theclass(2015, 12, 31, 14, 27, 0)) + + with self.assertRaises(ValueError): + self.theclass.fromisoformat('2015-01-07X00:00:00') + # Run with US-style DST rules: DST begins 2 a.m. on second Sunday in # March (M3.2.0) and ends 2 a.m. on first Sunday in November (M11.1.0). @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') @@ -2394,6 +2419,40 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase): self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.000000") self.assertEqual(t.isoformat(timespec='auto'), "12:34:56") + def test_fromisoformat(self): + # basic + self.assertEqual(self.theclass.fromisoformat('04:05:01.000123'), + self.theclass(4, 5, 1, 123)) + self.assertEqual(self.theclass.fromisoformat('00:00:00'), + self.theclass(0, 0, 0)) + # usec, rounding high + self.assertEqual(self.theclass.fromisoformat('10:20:30.40000059'), + self.theclass(10, 20, 30, 400001)) + # usec, rounding low + long digits we don't care about + self.assertEqual(self.theclass.fromisoformat('10:20:30.400003434'), + self.theclass(10, 20, 30, 400003)) + with self.assertRaises(ValueError): + self.theclass.fromisoformat('12:00AM') + with self.assertRaises(ValueError): + self.theclass.fromisoformat('120000') + with self.assertRaises(ValueError): + self.theclass.fromisoformat('1:00') + + def tz(h, m): + return timezone(timedelta(hours=h, minutes=m)) + + self.assertEqual(self.theclass.fromisoformat('00:00:00Z'), + self.theclass(0, 0, 0, tzinfo=timezone.utc)) + # lowercase UTC timezone. Uncommon but tolerated (rfc 3339) + self.assertEqual(self.theclass.fromisoformat('00:00:00z'), + self.theclass(0, 0, 0, tzinfo=timezone.utc)) + self.assertEqual(self.theclass.fromisoformat('00:00:00-00:00'), + self.theclass(0, 0, 0, tzinfo=tz(0, 0))) + self.assertEqual(self.theclass.fromisoformat('08:30:00.004255+02:30'), + self.theclass(8, 30, 0, 4255, tz(2, 30))) + self.assertEqual(self.theclass.fromisoformat('08:30:00.004255-02:30'), + self.theclass(8, 30, 0, 4255, tz(-2, -30))) + def test_1653736(self): # verify it doesn't accept extra keyword arguments t = self.theclass(second=1)