diff --git a/Lib/_strptime.py b/Lib/_strptime.py index fe94361..477e855 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -14,9 +14,11 @@ import time import locale import calendar from re import compile as re_compile -from re import IGNORECASE +from re import IGNORECASE, ASCII from re import escape as re_escape from datetime import (date as datetime_date, + time as datetime_time, + datetime as datetime_datetime, timedelta as datetime_timedelta, timezone as datetime_timezone) try: @@ -574,3 +576,41 @@ def _strptime_datetime(cls, data_string, format="%a %b %d %H:%M:%S %Y"): args += (tz,) return cls(*args) + + +_date_re = re_compile(r'(?P\d{4})-(?P\d{2})-(?P\d{2})$', ASCII) +_time_re = re_compile(r'(?P\d{2}):(?P\d{2}):(?P\d{2})' + r'(?P\.\d*)?(?PZ|([+-]\d{2}:\d{2}))?$', + ASCII | IGNORECASE) +_datetime_re = re_compile(_date_re.pattern[:-1] + r'[T ]' + _time_re.pattern, + ASCII | IGNORECASE) + +_isoregex_map = {datetime_date: _date_re, + datetime_time: _time_re, + datetime_datetime: _datetime_re, + } + + +def _parse_isodatetime(cls, string): + reg = _isoregex_map[cls] + match = reg.match(string) + if not match: + raise ValueError('invalid RFC 3339 %s string: %r' % (cls.__name__, string)) + kw = match.groupdict() + tzinfo = kw.pop('tzinfo', None) + if tzinfo == 'Z' or tzinfo == 'z': + tzinfo = datetime_timezone.utc + elif tzinfo is not None: + offset_hours, _, offset_mins = tzinfo[1:].partition(':') + offset = datetime_timedelta(hours=int(offset_hours), minutes=int(offset_mins)) + if tzinfo[0] == '-': + offset = -offset + tzinfo = datetime_timezone(offset) + us = kw.pop('microsecond', None) + kw = {k: int(v) for k, v in kw.items() if v} + if us: + us = round(float(us), 6) + kw['microsecond'] = int(us * 1e6) + if tzinfo: + kw['tzinfo'] = tzinfo + return cls(**kw) diff --git a/Lib/datetime.py b/Lib/datetime.py index 9f942a2..bbe86ef 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -729,6 +729,15 @@ class date: y, m, d = _ord2ymd(n) return cls(y, m, d) + @classmethod + def fromisoformat(cls, string): + """Construct a date from an RFC 3339 string, a strict subset of ISO 8601 + + Raises ValueError in case of ill-formatted or invalid string. + """ + import _strptime + return _strptime._parse_isodatetime(cls, string) + # Conversions to string def __repr__(self): @@ -1072,6 +1081,16 @@ class time: self._fold = fold return self + @classmethod + def fromisoformat(cls, string): + """Construct 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. + """ + import _strptime + return _strptime._parse_isodatetime(cls, string) + # Read-only field accessors @property def hour(self): @@ -1462,6 +1481,16 @@ class datetime(date): return cls._fromtimestamp(t, tz is not None, tz) @classmethod + def fromisoformat(cls, string): + """Construct a datetime 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. + """ + import _strptime + return _strptime._parse_isodatetime(cls, string) + + @classmethod def utcfromtimestamp(cls, t): """Construct a naive UTC datetime from a POSIX timestamp.""" return cls._fromtimestamp(t, True, None) 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) diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 3048762..33256d3 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -2129,6 +2129,25 @@ accum(const char* tag, PyObject *sofar, PyObject *num, PyObject *factor, return NULL; } +/* Return new cls from _strptime._parse_isodatetime(). */ +static PyObject * +datetime_fromisoformat(PyObject *cls, PyObject *args) +{ + static PyObject *module = NULL; + PyObject *string; + + if (!PyArg_ParseTuple(args, "U:fromisoformat", &string)) + return NULL; + + if (module == NULL) { + module = PyImport_ImportModule("_strptime"); + if (module == NULL) + return NULL; + } + + return PyObject_CallMethod(module, "_parse_isodatetime", "OO", cls, string); +} + static PyObject * delta_new(PyTypeObject *type, PyObject *args, PyObject *kw) { @@ -2885,6 +2904,11 @@ static PyMethodDef date_methods[] = { PyDoc_STR("timestamp -> local date from a POSIX timestamp (like " "time.time()).")}, + {"fromisoformat", (PyCFunction)datetime_fromisoformat, + METH_VARARGS | METH_CLASS, + PyDoc_STR("Construct a date from an RFC 3339 string, a strict subset of ISO 8601\n" + "Raises ValueError in case of ill-formatted or invalid string.\n")}, + {"fromordinal", (PyCFunction)date_fromordinal, METH_VARARGS | METH_CLASS, PyDoc_STR("int -> date corresponding to a proleptic Gregorian " @@ -3972,6 +3996,11 @@ time_reduce(PyDateTime_Time *self, PyObject *args) static PyMethodDef time_methods[] = { + {"fromisoformat", (PyCFunction)datetime_fromisoformat, + METH_VARARGS | METH_CLASS, + PyDoc_STR("Construct a datetime from an RFC 3339 string, a strict subset of ISO 8601\n" + "Raises ValueError in case of ill-formatted or invalid string.\n")}, + {"isoformat", (PyCFunction)time_isoformat, METH_VARARGS | METH_KEYWORDS, PyDoc_STR("Return string in ISO 8601 format, [HH[:MM[:SS[.mmm[uuu]]]]]" "[+HH:MM].\n\n" @@ -4406,7 +4435,7 @@ datetime_utcfromtimestamp(PyObject *cls, PyObject *args) return result; } -/* Return new datetime from _strptime.strptime_datetime(). */ +/* Return new datetime from _strptime._strptime_datetime(). */ static PyObject * datetime_strptime(PyObject *cls, PyObject *args) { @@ -5445,6 +5474,12 @@ static PyMethodDef datetime_methods[] = { METH_VARARGS | METH_KEYWORDS | METH_CLASS, PyDoc_STR("timestamp[, tz] -> tz's local time from POSIX timestamp.")}, + {"fromisoformat", (PyCFunction)datetime_fromisoformat, + METH_VARARGS | METH_CLASS, + PyDoc_STR("Construct a datetime from an RFC 3339 string, a strict subset of ISO 8601\n" + "Microseconds are rounded to 6 digits.\n" + "Raises ValueError in case of ill-formatted or invalid string.\n")}, + {"utcfromtimestamp", (PyCFunction)datetime_utcfromtimestamp, METH_VARARGS | METH_CLASS, PyDoc_STR("Construct a naive UTC datetime from a POSIX timestamp.")},