diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index a82284283bf..d73e97b1cf8 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -1134,7 +1134,7 @@ Instance methods: ``self.date().isocalendar()``. -.. method:: datetime.isoformat(sep='T') +.. method:: datetime.isoformat(sep='T', timespec='auto') Return a string representing the date and time in ISO 8601 format, YYYY-MM-DDTHH:MM:SS.mmmmmm or, if :attr:`microsecond` is 0, @@ -1155,6 +1155,37 @@ Instance methods: >>> datetime(2002, 12, 25, tzinfo=TZ()).isoformat(' ') '2002-12-25 00:00:00-06:39' + The optional argument *timespec* specifies the number of additional + components of the time to include (the default is ``'auto'``). + It can be one of the following: + + - ``'auto'``: Same as ``'seconds'`` if :attr:`microsecond` is 0, + same as ``'microseconds'`` otherwise. + - ``'hours'``: Include the :attr:`hour` in the two-digit HH format. + - ``'minutes'``: Include :attr:`hour` and :attr:`minute` in HH:MM format. + - ``'seconds'``: Include :attr:`hour`, :attr:`minute`, and :attr:`second` + in HH:MM:SS format. + - ``'milliseconds'``: Include full time, but truncate fractional second + part to milliseconds. HH:MM:SS.sss format. + - ``'microseconds'``: Include full time in HH:MM:SS.mmmmmm format. + + .. note:: + + Excluded time components are truncated, not rounded. + + :exc:`ValueError` will be raised on an invalid *timespec* argument. + + + >>> from datetime import datetime + >>> datetime.now().isoformat(timespec='minutes') + '2002-12-25T00:00' + >>> dt = datetime(2015, 1, 1, 12, 30, 59, 0) + >>> dt.isoformat(timespec='microseconds') + '2015-01-01T12:30:59.000000' + + .. versionadded:: 3.6 + Added the *timespec* argument. + .. method:: datetime.__str__() @@ -1404,13 +1435,46 @@ Instance methods: aware :class:`.time`, without conversion of the time data. -.. method:: time.isoformat() +.. method:: time.isoformat(timespec='auto') Return a string representing the time in ISO 8601 format, HH:MM:SS.mmmmmm or, if - self.microsecond is 0, HH:MM:SS If :meth:`utcoffset` does not return ``None``, a + :attr:`microsecond` is 0, HH:MM:SS If :meth:`utcoffset` does not return ``None``, a 6-character string is appended, giving the UTC offset in (signed) hours and minutes: HH:MM:SS.mmmmmm+HH:MM or, if self.microsecond is 0, HH:MM:SS+HH:MM + The optional argument *timespec* specifies the number of additional + components of the time to include (the default is ``'auto'``). + It can be one of the following: + + - ``'auto'``: Same as ``'seconds'`` if :attr:`microsecond` is 0, + same as ``'microseconds'`` otherwise. + - ``'hours'``: Include the :attr:`hour` in the two-digit HH format. + - ``'minutes'``: Include :attr:`hour` and :attr:`minute` in HH:MM format. + - ``'seconds'``: Include :attr:`hour`, :attr:`minute`, and :attr:`second` + in HH:MM:SS format. + - ``'milliseconds'``: Include full time, but truncate fractional second + part to milliseconds. HH:MM:SS.sss format. + - ``'microseconds'``: Include full time in HH:MM:SS.mmmmmm format. + + .. note:: + + Excluded time components are truncated, not rounded. + + :exc:`ValueError` will be raised on an invalid *timespec* argument. + + + >>> from datetime import time + >>> time(hours=12, minute=34, second=56, microsecond=123456).isoformat(timespec='minutes') + '12:34' + >>> dt = time(hours=12, minute=34, second=56, microsecond=0) + >>> dt.isoformat(timespec='microseconds') + '12:34:56.000000' + >>> dt.isoformat(timespec='auto') + '12:34:56' + + .. versionadded:: 3.6 + Added the *timespec* argument. + .. method:: time.__str__() diff --git a/Lib/datetime.py b/Lib/datetime.py index 3206923cb3f..d4b7fc45d5b 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -152,12 +152,26 @@ def _build_struct_time(y, m, d, hh, mm, ss, dstflag): dnum = _days_before_month(y, m) + d return _time.struct_time((y, m, d, hh, mm, ss, wday, dnum, dstflag)) -def _format_time(hh, mm, ss, us): - # Skip trailing microseconds when us==0. - result = "%02d:%02d:%02d" % (hh, mm, ss) - if us: - result += ".%06d" % us - return result +def _format_time(hh, mm, ss, us, timespec='auto'): + specs = { + 'hours': '{:02d}', + 'minutes': '{:02d}:{:02d}', + 'seconds': '{:02d}:{:02d}:{:02d}', + 'milliseconds': '{:02d}:{:02d}:{:02d}.{:03d}', + 'microseconds': '{:02d}:{:02d}:{:02d}.{:06d}' + } + + if timespec == 'auto': + # Skip trailing microseconds when us==0. + timespec = 'microseconds' if us else 'seconds' + elif timespec == 'milliseconds': + us //= 1000 + try: + fmt = specs[timespec] + except KeyError: + raise ValueError('Unknown timespec value') + else: + return fmt.format(hh, mm, ss, us) # Correctly substitute for %z and %Z escapes in strftime formats. def _wrap_strftime(object, format, timetuple): @@ -1194,14 +1208,17 @@ class time: s = s[:-1] + ", tzinfo=%r" % self._tzinfo + ")" return s - def isoformat(self): + def isoformat(self, timespec='auto'): """Return the time formatted according to ISO. - This is 'HH:MM:SS.mmmmmm+zz:zz', or 'HH:MM:SS+zz:zz' if - self.microsecond == 0. + The full format is 'HH:MM:SS.mmmmmm+zz:zz'. By default, the fractional + part is omitted if self.microsecond == 0. + + The optional argument timespec specifies the number of additional + terms of the time to include. """ s = _format_time(self._hour, self._minute, self._second, - self._microsecond) + self._microsecond, timespec) tz = self._tzstr() if tz: s += tz @@ -1550,21 +1567,25 @@ class datetime(date): self._hour, self._minute, self._second, self._year) - def isoformat(self, sep='T'): + def isoformat(self, sep='T', timespec='auto'): """Return the time formatted according to ISO. - This is 'YYYY-MM-DD HH:MM:SS.mmmmmm', or 'YYYY-MM-DD HH:MM:SS' if - self.microsecond == 0. + The full format looks like 'YYYY-MM-DD HH:MM:SS.mmmmmm'. + By default, the fractional part is omitted if self.microsecond == 0. If self.tzinfo is not None, the UTC offset is also attached, giving - 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM' or 'YYYY-MM-DD HH:MM:SS+HH:MM'. + giving a full format of 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM'. Optional argument sep specifies the separator between date and time, default 'T'. + + The optional argument timespec specifies the number of additional + terms of the time to include. """ s = ("%04d-%02d-%02d%c" % (self._year, self._month, self._day, sep) + _format_time(self._hour, self._minute, self._second, - self._microsecond)) + self._microsecond, timespec)) + off = self.utcoffset() if off is not None: if off.days < 0: diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 68c18bd9bde..b49942ac737 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -1556,13 +1556,32 @@ class TestDateTime(TestDate): self.assertEqual(dt, dt2) def test_isoformat(self): - t = self.theclass(2, 3, 2, 4, 5, 1, 123) - self.assertEqual(t.isoformat(), "0002-03-02T04:05:01.000123") - self.assertEqual(t.isoformat('T'), "0002-03-02T04:05:01.000123") - self.assertEqual(t.isoformat(' '), "0002-03-02 04:05:01.000123") - self.assertEqual(t.isoformat('\x00'), "0002-03-02\x0004:05:01.000123") + t = self.theclass(1, 2, 3, 4, 5, 1, 123) + self.assertEqual(t.isoformat(), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat('T'), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(' '), "0001-02-03 04:05:01.000123") + self.assertEqual(t.isoformat('\x00'), "0001-02-03\x0004:05:01.000123") + self.assertEqual(t.isoformat(timespec='hours'), "0001-02-03T04") + self.assertEqual(t.isoformat(timespec='minutes'), "0001-02-03T04:05") + self.assertEqual(t.isoformat(timespec='seconds'), "0001-02-03T04:05:01") + self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.000") + self.assertEqual(t.isoformat(timespec='microseconds'), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(timespec='auto'), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(sep=' ', timespec='minutes'), "0001-02-03 04:05") + self.assertRaises(ValueError, t.isoformat, timespec='foo') # str is ISO format with the separator forced to a blank. - self.assertEqual(str(t), "0002-03-02 04:05:01.000123") + self.assertEqual(str(t), "0001-02-03 04:05:01.000123") + + t = self.theclass(1, 2, 3, 4, 5, 1, 999500, tzinfo=timezone.utc) + self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.999+00:00") + + t = self.theclass(1, 2, 3, 4, 5, 1, 999500) + self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.999") + + t = self.theclass(1, 2, 3, 4, 5, 1) + self.assertEqual(t.isoformat(timespec='auto'), "0001-02-03T04:05:01") + self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.000") + self.assertEqual(t.isoformat(timespec='microseconds'), "0001-02-03T04:05:01.000000") t = self.theclass(2, 3, 2) self.assertEqual(t.isoformat(), "0002-03-02T00:00:00") @@ -2322,6 +2341,23 @@ class TestTime(HarmlessMixedComparison, unittest.TestCase): self.assertEqual(t.isoformat(), "00:00:00.100000") self.assertEqual(t.isoformat(), str(t)) + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456) + self.assertEqual(t.isoformat(timespec='hours'), "12") + self.assertEqual(t.isoformat(timespec='minutes'), "12:34") + self.assertEqual(t.isoformat(timespec='seconds'), "12:34:56") + self.assertEqual(t.isoformat(timespec='milliseconds'), "12:34:56.123") + self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.123456") + self.assertEqual(t.isoformat(timespec='auto'), "12:34:56.123456") + self.assertRaises(ValueError, t.isoformat, timespec='monkey') + + t = self.theclass(hour=12, minute=34, second=56, microsecond=999500) + self.assertEqual(t.isoformat(timespec='milliseconds'), "12:34:56.999") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=0) + self.assertEqual(t.isoformat(timespec='milliseconds'), "12:34:56.000") + self.assertEqual(t.isoformat(timespec='microseconds'), "12:34:56.000000") + self.assertEqual(t.isoformat(timespec='auto'), "12:34:56") + def test_1653736(self): # verify it doesn't accept extra keyword arguments t = self.theclass(second=1) diff --git a/Misc/ACKS b/Misc/ACKS index 5e6fee7935f..adc68485629 100644 --- a/Misc/ACKS +++ b/Misc/ACKS @@ -309,6 +309,7 @@ Laura Creighton Simon Cross Felipe Cruz Drew Csillag +Alessandro Cucci Joaquin Cuenca Abela John Cugini Tom Culliton diff --git a/Misc/NEWS b/Misc/NEWS index 9c94db324dc..d90422d61c0 100644 --- a/Misc/NEWS +++ b/Misc/NEWS @@ -201,6 +201,9 @@ Core and Builtins Library ------- +- Issue #19475: Added an optional argument timespec to the datetime + isoformat() method to choose the precision of the time component. + - Issue #2202: Fix UnboundLocalError in AbstractDigestAuthHandler.get_algorithm_impls. Initial patch by Mathieu Dupuy. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 4c8f3f6fa29..347a9d90dc8 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -3608,23 +3608,56 @@ time_str(PyDateTime_Time *self) } static PyObject * -time_isoformat(PyDateTime_Time *self, PyObject *unused) +time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) { char buf[100]; + char *timespec = NULL; + static char *keywords[] = {"timespec", NULL}; PyObject *result; int us = TIME_GET_MICROSECOND(self); + static char *specs[][2] = { + {"hours", "%02d"}, + {"minutes", "%02d:%02d"}, + {"seconds", "%02d:%02d:%02d"}, + {"milliseconds", "%02d:%02d:%02d.%03d"}, + {"microseconds", "%02d:%02d:%02d.%06d"}, + }; + size_t given_spec; - if (us) - result = PyUnicode_FromFormat("%02d:%02d:%02d.%06d", - TIME_GET_HOUR(self), - TIME_GET_MINUTE(self), - TIME_GET_SECOND(self), - us); - else - result = PyUnicode_FromFormat("%02d:%02d:%02d", - TIME_GET_HOUR(self), - TIME_GET_MINUTE(self), - TIME_GET_SECOND(self)); + if (!PyArg_ParseTupleAndKeywords(args, kw, "|s:isoformat", keywords, ×pec)) + return NULL; + + if (timespec == NULL || strcmp(timespec, "auto") == 0) { + if (us == 0) { + /* seconds */ + given_spec = 2; + } + else { + /* microseconds */ + given_spec = 4; + } + } + else { + for (given_spec = 0; given_spec < Py_ARRAY_LENGTH(specs); given_spec++) { + if (strcmp(timespec, specs[given_spec][0]) == 0) { + if (given_spec == 3) { + /* milliseconds */ + us = us / 1000; + } + break; + } + } + } + + if (given_spec == Py_ARRAY_LENGTH(specs)) { + PyErr_Format(PyExc_ValueError, "Unknown timespec value"); + return NULL; + } + else { + result = PyUnicode_FromFormat(specs[given_spec][1], + TIME_GET_HOUR(self), TIME_GET_MINUTE(self), + TIME_GET_SECOND(self), us); + } if (result == NULL || !HASTZINFO(self) || self->tzinfo == Py_None) return result; @@ -3845,9 +3878,10 @@ time_reduce(PyDateTime_Time *self, PyObject *arg) static PyMethodDef time_methods[] = { - {"isoformat", (PyCFunction)time_isoformat, METH_NOARGS, - PyDoc_STR("Return string in ISO 8601 format, HH:MM:SS[.mmmmmm]" - "[+HH:MM].")}, + {"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" + "timespec specifies what components of the time to include.\n")}, {"strftime", (PyCFunction)time_strftime, METH_VARARGS | METH_KEYWORDS, PyDoc_STR("format -> strftime() style string.")}, @@ -4476,25 +4510,55 @@ static PyObject * datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) { int sep = 'T'; - static char *keywords[] = {"sep", NULL}; + char *timespec = NULL; + static char *keywords[] = {"sep", "timespec", NULL}; char buffer[100]; - PyObject *result; + PyObject *result = NULL; int us = DATE_GET_MICROSECOND(self); + static char *specs[][2] = { + {"hours", "%04d-%02d-%02d%c%02d"}, + {"minutes", "%04d-%02d-%02d%c%02d:%02d"}, + {"seconds", "%04d-%02d-%02d%c%02d:%02d:%02d"}, + {"milliseconds", "%04d-%02d-%02d%c%02d:%02d:%02d.%03d"}, + {"microseconds", "%04d-%02d-%02d%c%02d:%02d:%02d.%06d"}, + }; + size_t given_spec; - if (!PyArg_ParseTupleAndKeywords(args, kw, "|C:isoformat", keywords, &sep)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "|Cs:isoformat", keywords, &sep, ×pec)) return NULL; - if (us) - result = PyUnicode_FromFormat("%04d-%02d-%02d%c%02d:%02d:%02d.%06d", + + if (timespec == NULL || strcmp(timespec, "auto") == 0) { + if (us == 0) { + /* seconds */ + given_spec = 2; + } + else { + /* microseconds */ + given_spec = 4; + } + } + else { + for (given_spec = 0; given_spec < Py_ARRAY_LENGTH(specs); given_spec++) { + if (strcmp(timespec, specs[given_spec][0]) == 0) { + if (given_spec == 3) { + us = us / 1000; + } + break; + } + } + } + + if (given_spec == Py_ARRAY_LENGTH(specs)) { + PyErr_Format(PyExc_ValueError, "Unknown timespec value"); + return NULL; + } + else { + result = PyUnicode_FromFormat(specs[given_spec][1], GET_YEAR(self), GET_MONTH(self), GET_DAY(self), (int)sep, DATE_GET_HOUR(self), DATE_GET_MINUTE(self), DATE_GET_SECOND(self), us); - else - result = PyUnicode_FromFormat("%04d-%02d-%02d%c%02d:%02d:%02d", - GET_YEAR(self), GET_MONTH(self), - GET_DAY(self), (int)sep, - DATE_GET_HOUR(self), DATE_GET_MINUTE(self), - DATE_GET_SECOND(self)); + } if (!result || !HASTZINFO(self)) return result; @@ -5028,9 +5092,12 @@ static PyMethodDef datetime_methods[] = { {"isoformat", (PyCFunction)datetime_isoformat, METH_VARARGS | METH_KEYWORDS, PyDoc_STR("[sep] -> string in ISO 8601 format, " - "YYYY-MM-DDTHH:MM:SS[.mmmmmm][+HH:MM].\n\n" + "YYYY-MM-DDT[HH[:MM[:SS[.mmm[uuu]]]]][+HH:MM].\n" "sep is used to separate the year from the time, and " - "defaults to 'T'.")}, + "defaults to 'T'.\n" + "timespec specifies what components of the time to include" + " (allowed values are 'auto', 'hours', 'minutes', 'seconds'," + " 'milliseconds', and 'microseconds').\n")}, {"utcoffset", (PyCFunction)datetime_utcoffset, METH_NOARGS, PyDoc_STR("Return self.tzinfo.utcoffset(self).")},