diff --git a/Lib/datetime.py b/Lib/datetime.py index 3206923..26facaf 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -6,6 +6,14 @@ time zone and DST data sources. import time as _time import math as _math +import re + +DATE_RE = re.compile(r'(?P\d{4})-(?P\d{2})-(?P\d{2})\$', re.ASCII) +TIME_RE = re.compile(r'(?P\d{2}):(?P\d{2}):(?P\d{2})' + r'(?:(?P\.\d{1,7})\d*)?(?:(?PZ|([+-]\d{2}:\d{2})))?\$', + re.ASCII|re.IGNORECASE) +DATETIME_RE = re.compile(DATE_RE.pattern[:-1] + r'[T ]' + TIME_RE.pattern, + re.ASCII|re.IGNORECASE) def _cmp(x, y): return 0 if x == y else 1 if x > y else -1 @@ -715,6 +723,18 @@ class date: y, m, d = _ord2ymd(n) return cls(y, m, d) + @classmethod + def fromisoformat(cls, date_string): + """ Construct a date from an iso 8601 string + + """ + m = DATE_RE.match(date_string) + if not m: + raise ValueError('invalid date string', date_string) + kw = m.groupdict() + kw = {k: int(v) for k, v in kw.items()} + return cls(**kw) + # Conversions to string def __repr__(self): @@ -1055,6 +1075,37 @@ class time: self._hashcode = -1 return self + @staticmethod + def _parse_isotime(reg, isostring, objekt): + match = reg.match(isostring) + if not match: + raise ValueError('invalid iso8601 %s string: "%s"' % (objekt, 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:].split(':') + 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) + kw['tzinfo'] = tzinfo + return kw + + @classmethod + def fromisoformat(cls, time_string): + """ Constructs a time object from an iso 8601 string + """ + kw = cls._parse_isotime(TIME_RE, time_string, __name__) + return cls(**kw) + + # Read-only field accessors @property def hour(self): @@ -1428,6 +1479,11 @@ class datetime(date): time.hour, time.minute, time.second, time.microsecond, time.tzinfo) + @classmethod + def fromisoformat(cls, datetime_string): + kw = time._parse_isotime(DATETIME_RE, datetime_string, __name__) + return cls(**kw) + def timetuple(self): "Return local time tuple compatible with time.localtime()." dst = self.dst() diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 68c18bd..8a98045 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1093,6 +1093,17 @@ 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') + self.theclass.fromisoformat('20141211') + 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 @@ -1863,6 +1874,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') @@ -2322,6 +2345,38 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase): self.assertEqual(t.isoformat(), "00:00:00.100000") self.assertEqual(t.isoformat(), str(t)) + 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.4000059'), + self.theclass(10, 20, 30, 400006)) + # 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') + self.theclass.fromisoformat('120000') + 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)