From 150f7df8d92b55c8bc187584ea7644d212037d63 Mon Sep 17 00:00:00 2001 From: Mathieu Dupuy Date: Wed, 13 Dec 2017 14:48:46 +0100 Subject: [PATCH] bpo-15873: add '.fromisoformat' for date, time and datetime --- Doc/library/datetime.rst | 42 +++++++++++++++++++++ Lib/_strptime.py | 51 ++++++++++++++++++++++++- Lib/datetime.py | 29 +++++++++++++++ Lib/test/datetimetester.py | 61 ++++++++++++++++++++++++++++++ Modules/_datetimemodule.c | 76 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 1 deletion(-) diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index dce51a16e868a4..f58ffa4bfd354f 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -428,6 +428,19 @@ Other constructors, all class methods: :exc:`ValueError` on :c:func:`localtime` failure. +.. classmethod:: date.fromisoformat(date_string) + + Return a date object corresponding to *date_string*, according to RFC 3339, + a stricter, simpler subset of ISO 8601, and such as is returned by + :func:`date.isoformat`. Microseconds are rounded to 6 digits. + :exc:`ValueError` is raised if *date_string* is not a valid RFC 3339 + date string. + + .. `RFC 3339`: https://www.ietf.org/rfc/rfc3339.txt + + .. versionadded:: 3.7 + + .. classmethod:: date.fromordinal(ordinal) Return the date corresponding to the proleptic Gregorian ordinal, where January @@ -793,6 +806,19 @@ Other constructors, all class methods: :exc:`ValueError` on :c:func:`gmtime` failure. +.. classmethod:: datetime.fromisoformat(datetime_string) + + Return a datetime object corresponding to *datetime_string*, according to RFC 3339, + a stricter, simpler subset of ISO 8601, and such as is returned by + :func:`datetime.isoformat`. Microseconds are rounded to 6 digits. + :exc:`ValueError` is raised if *datetime_string* is not a valid RFC 3339 + datetime string. + + .. `RFC 3339`: https://www.ietf.org/rfc/rfc3339.txt + + .. versionadded:: 3.7 + + .. classmethod:: datetime.fromordinal(ordinal) Return the :class:`.datetime` corresponding to the proleptic Gregorian ordinal, @@ -1394,6 +1420,22 @@ day, and subject to adjustment via a :class:`tzinfo` object. If an argument outside those ranges is given, :exc:`ValueError` is raised. All default to ``0`` except *tzinfo*, which defaults to :const:`None`. + +Other constructor: + +.. classmethod:: time.fromisoformat(string) + + Return a time object corresponding to *time_string*, according to RFC 3339, + a stricter, simpler subset of ISO 8601, and such as is returned by + :func:`time.isoformat`. Microseconds are rounded to 6 digits. + :exc:`ValueError` is raised if *time_string* is not a valid RFC 3339 + time string.. + + .. `RFC 3339`: https://www.ietf.org/rfc/rfc3339.txt + + .. versionadded:: 3.7 + + Class attributes: diff --git a/Lib/_strptime.py b/Lib/_strptime.py index f5195af90c8a4d..b75bc53a4e45e4 100644 --- a/Lib/_strptime.py +++ b/Lib/_strptime.py @@ -14,7 +14,7 @@ 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, timedelta as datetime_timedelta, @@ -27,6 +27,55 @@ def _getlang(): # Figure out what the current language is set to. return locale.getlocale(locale.LC_TIME) + +_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) + + +def _parse_isodate(cls, isostring): + return _parse_isoformat(cls, isostring, _date_re) + + +def _parse_isotime(cls, isostring): + return _parse_isoformat(cls, isostring, _time_re) + + +def _parse_isodatetime(cls, isostring): + return _parse_isoformat(cls, isostring, _datetime_re) + + +def _parse_isoformat(cls, isostring, iso_re): + match = iso_re.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 = 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 us: + us = round(float(us), 6) + kw['microsecond'] = int(us * 1e6) + if tzinfo: + kw['tzinfo'] = tzinfo + return cls(**kw) + + class LocaleTime(object): """Stores and handles locale-specific information related to time. diff --git a/Lib/datetime.py b/Lib/datetime.py index 67d8600921c382..499a238d22e865 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -732,6 +732,15 @@ def fromordinal(cls, n): 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. + """ + import _strptime + return _strptime._parse_isodate(cls, date_string) + # Conversions to string def __repr__(self): @@ -1075,6 +1084,16 @@ def __new__(cls, hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold 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. + """ + import _strptime + return _strptime._parse_isotime(cls, time_string) + # Read-only field accessors @property def hour(self): @@ -1472,6 +1491,16 @@ def utcfromtimestamp(cls, t): """Construct a naive UTC datetime from a POSIX timestamp.""" return cls._fromtimestamp(t, True, None) + @classmethod + def fromisoformat(cls, datetime_string): + """Constructs 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, datetime_string) + @classmethod def now(cls, tz=None): "Construct a datetime from time.time() and optional time zone info." diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index d0886c47baec17..be1185e27fc708 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1166,6 +1166,19 @@ def test_fromtimestamp(self): 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 @@ -1976,6 +1989,18 @@ def test_utcfromtimestamp(self): 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') @@ -2517,6 +2542,42 @@ def test_isoformat(self): 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') + with self.assertRaises(ValueError): + self.theclass.fromisoformat('17:54:43.') + + 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 b50cddad5dd2fd..c378f1c8d45c7d 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -2607,6 +2607,25 @@ date_fromtimestamp(PyObject *cls, PyObject *args) return result; } +/* Return new date from given date string, using _strptime._parse_isodate(). */ +static PyObject * +date_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_isodate", "OO", cls, string); +} + /* Return new date from proleptic Gregorian ordinal. Raises ValueError if * the ordinal is out of range. */ @@ -2920,6 +2939,11 @@ static PyMethodDef date_methods[] = { PyDoc_STR("timestamp -> local date from a POSIX timestamp (like " "time.time()).")}, + {"fromisoformat", (PyCFunction)date_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 " @@ -3711,6 +3735,26 @@ time_str(PyDateTime_Time *self) return _PyObject_CallMethodId((PyObject *)self, &PyId_isoformat, NULL); } +/* Return new time from time string, using _strptime._parse_isotime(). */ +static PyObject * +time_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_isotime", "OO", cls, string); +} + static PyObject * time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) { @@ -4018,6 +4062,13 @@ time_reduce(PyDateTime_Time *self, PyObject *arg) static PyMethodDef time_methods[] = { + {"fromisoformat", (PyCFunction)time_fromisoformat, + METH_VARARGS | METH_CLASS, + PyDoc_STR("Construct a time 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")}, + {"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" @@ -4726,6 +4777,25 @@ datetime_str(PyDateTime_DateTime *self) return _PyObject_CallMethodId((PyObject *)self, &PyId_isoformat, "s", " "); } +/* Return new datetime 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 * datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) { @@ -5506,6 +5576,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.")},