From 03fbca3cfe86cd8bf0b00ac39ecdf635ba17ee31 Mon Sep 17 00:00:00 2001 From: Matt Wozniski Date: Mon, 21 Mar 2022 23:29:49 -0400 Subject: [PATCH] Allow datetime.isoformat to use "Z" UTC designator Add an optional parameter to datetime.isoformat() and time.isoformat() called use_utc_designator. If provided and True and the object is associated with a tzinfo and its tzname is exactly "UTC", the formatted UTC offset will be "Z" rather than an offset from UTC. --- Doc/library/datetime.rst | 31 +++++++- Lib/datetime.py | 32 +++++--- Lib/test/datetimetester.py | 74 +++++++++++++++++++ .../2022-03-22-00-27-11.bpo-46614.Rgt_1Y.rst | 4 + Modules/_datetimemodule.c | 42 ++++++++--- 5 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2022-03-22-00-27-11.bpo-46614.Rgt_1Y.rst diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index f447b7bc9491e4..44bc6973c11c7a 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -1409,7 +1409,7 @@ Instance methods: and ``weekday``. The same as ``self.date().isocalendar()``. -.. method:: datetime.isoformat(sep='T', timespec='auto') +.. method:: datetime.isoformat(sep='T', timespec='auto', use_utc_designator=False) Return a string representing the date and time in ISO 8601 format: @@ -1473,9 +1473,22 @@ Instance methods: >>> dt.isoformat(timespec='microseconds') '2015-01-01T12:30:59.000000' + If the optional argument *use_utc_designator* is set to :const:`True` and + :meth:`tzname` returns exactly ``"UTC"``, then "Z" will be given as the UTC + offset in the formatted string:: + + >>> from datetime import datetime, timezone + >>> dt = datetime(2022, 3, 21, 12, 30, 59, tzinfo=timezone.utc) + >>> dt.isoformat() + '2022-03-21T12:30:59+00:00' + >>> dt.isoformat(use_utc_designator=True) + '2022-03-21T12:30:59Z' + .. versionadded:: 3.6 Added the *timespec* argument. + .. versionadded:: 3.11 + Added the *use_utc_designator* argument. .. method:: datetime.__str__() @@ -1797,7 +1810,7 @@ Instance methods: Added the ``fold`` argument. -.. method:: time.isoformat(timespec='auto') +.. method:: time.isoformat(timespec='auto', use_utc_designator=False) Return a string representing the time in ISO 8601 format, one of: @@ -1826,9 +1839,13 @@ Instance methods: :exc:`ValueError` will be raised on an invalid *timespec* argument. + If the optional argument *use_utc_designator* is set to :const:`True` and + :meth:`tzname` returns exactly ``"UTC"``, then "Z" will be given as the UTC + offset in the formatted string. + Example:: - >>> from datetime import time + >>> from datetime import time, timezone >>> time(hour=12, minute=34, second=56, microsecond=123456).isoformat(timespec='minutes') '12:34' >>> dt = time(hour=12, minute=34, second=56, microsecond=0) @@ -1836,10 +1853,18 @@ Instance methods: '12:34:56.000000' >>> dt.isoformat(timespec='auto') '12:34:56' + >>> dt = time(12, 30, 59, tzinfo=timezone.utc) + >>> dt.isoformat() + '12:30:59+00:00' + >>> dt.isoformat(use_utc_designator=True) + '12:30:59Z' .. versionadded:: 3.6 Added the *timespec* argument. + .. versionadded:: 3.11 + Added the *use_utc_designator* argument. + .. method:: time.__str__() diff --git a/Lib/datetime.py b/Lib/datetime.py index 6bf37ccfab7ac8..f9b56dd8824e7d 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -1414,11 +1414,13 @@ def __repr__(self): s = s[:-1] + ", fold=1)" return s - def isoformat(self, timespec='auto'): + def isoformat(self, timespec='auto', use_utc_designator=False): """Return the time formatted according to ISO. The full format is 'HH:MM:SS.mmmmmm+zz:zz'. By default, the fractional - part is omitted if self.microsecond == 0. + part is omitted if self.microsecond == 0. The UTC offset will be + replaced with a single "Z" if use_utc_designator is True and + self.tzname() is exactly "UTC" The optional argument timespec specifies the number of additional terms of the time to include. Valid options are 'auto', 'hours', @@ -1426,9 +1428,12 @@ def isoformat(self, timespec='auto'): """ s = _format_time(self._hour, self._minute, self._second, self._microsecond, timespec) - tz = self._tzstr() - if tz: - s += tz + if use_utc_designator and "UTC" == self.tzname(): + s += "Z" + else: + tz = self._tzstr() + if tz: + s += tz return s __str__ = isoformat @@ -1894,14 +1899,16 @@ def ctime(self): self._hour, self._minute, self._second, self._year) - def isoformat(self, sep='T', timespec='auto'): + def isoformat(self, sep='T', timespec='auto', use_utc_designator=False): """Return the time formatted according to ISO. 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 - giving a full format of 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM'. + If self.tzinfo is not None, the UTC offset is also attached. If the + optional argument use_utc_designator is True and the timezone name is + "UTC", a Z is appended: 'YYYY-MM-DD HH:MM:SS.mmmmmmZ'. + Otherwise the format is 'YYYY-MM-DD HH:MM:SS.mmmmmm+HH:MM'. Optional argument sep specifies the separator between date and time, default 'T'. @@ -1915,9 +1922,12 @@ def isoformat(self, sep='T', timespec='auto'): self._microsecond, timespec)) off = self.utcoffset() - tz = _format_offset(off) - if tz: - s += tz + if use_utc_designator and "UTC" == self.tzname(): + s += "Z" + else: + tz = _format_offset(off) + if tz: + s += tz return s diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index efea40d3f4ff1f..ed9e640e3865e8 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -2010,6 +2010,8 @@ def test_isoformat(self): 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.assertEqual(t.isoformat(use_utc_designator=False), "0001-02-03T04:05:01.000123") + self.assertEqual(t.isoformat(use_utc_designator=True), "0001-02-03T04:05:01.000123") self.assertRaises(ValueError, t.isoformat, timespec='foo') # bpo-34482: Check that surrogates are handled properly. self.assertRaises(ValueError, t.isoformat, timespec='\ud800') @@ -2018,6 +2020,8 @@ def test_isoformat(self): 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") + self.assertEqual(t.isoformat(use_utc_designator=False), "0001-02-03T04:05:01.999500+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "0001-02-03T04:05:01.999500Z") t = self.theclass(1, 2, 3, 4, 5, 1, 999500) self.assertEqual(t.isoformat(timespec='milliseconds'), "0001-02-03T04:05:01.999") @@ -2037,6 +2041,8 @@ def test_isoformat(self): tz = FixedOffset(timedelta(seconds=16), 'XXX') t = self.theclass(2, 3, 2, tzinfo=tz) self.assertEqual(t.isoformat(), "0002-03-02T00:00:00+00:00:16") + self.assertEqual(t.isoformat(use_utc_designator=False), "0002-03-02T00:00:00+00:00:16") + self.assertEqual(t.isoformat(use_utc_designator=True), "0002-03-02T00:00:00+00:00:16") def test_isoformat_timezone(self): tzoffsets = [ @@ -3276,6 +3282,74 @@ def test_isoformat_timezone(self): with self.subTest(tzi=tzi): assert t.isoformat() == exp + def test_isoformat_utc_designator(self): + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456) + self.assertEqual(t.isoformat(), "12:34:56.123456") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=timezone.utc) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456Z") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=timezone(timedelta(0))) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456Z") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=timezone(timedelta(0), "UTC")) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456Z") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=timezone(timedelta(0), "GMT")) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456+00:00") + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=timezone(timedelta(hours=5), "UTC")) + self.assertEqual(t.isoformat(), "12:34:56.123456+05:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+05:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456Z") + + class UnnamedTimezone(tzinfo): + def utcoffset(self, dt): + return timedelta(0) + + def dst(self, dt): + return timedelta(0) + + def tzname(self, dt): + return None + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=UnnamedTimezone()) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456+00:00") + + class NonStringNamedTimezone(tzinfo): + def utcoffset(self, dt): + return timedelta(0) + + def dst(self, dt): + return timedelta(0) + + def tzname(self, dt): + return 42 + + t = self.theclass(hour=12, minute=34, second=56, microsecond=123456, + tzinfo=UnnamedTimezone()) + self.assertEqual(t.isoformat(), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=False), "12:34:56.123456+00:00") + self.assertEqual(t.isoformat(use_utc_designator=True), "12:34:56.123456+00:00") + def test_1653736(self): # verify it doesn't accept extra keyword arguments t = self.theclass(second=1) diff --git a/Misc/NEWS.d/next/Library/2022-03-22-00-27-11.bpo-46614.Rgt_1Y.rst b/Misc/NEWS.d/next/Library/2022-03-22-00-27-11.bpo-46614.Rgt_1Y.rst new file mode 100644 index 00000000000000..eb5940805bb52f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2022-03-22-00-27-11.bpo-46614.Rgt_1Y.rst @@ -0,0 +1,4 @@ +Add *use_utc_designator* as an optional parameter to +:meth:`datetime.datetime.isoformat` and :meth:`datetime.time.isoformat`. If +it's set to true, the UTC offset will be formatted as "Z" rather than "+00:00" +if the object is associated with a timezone named exactly ``"UTC"``. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index ae97190bccbde0..6f2f6cb03b7e3b 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -1357,8 +1357,10 @@ format_ctime(PyDateTime_Date *date, int hours, int minutes, int seconds) static PyObject *delta_negative(PyDateTime_Delta *self); /* Add formatted UTC offset string to buf. buf has no more than - * buflen bytes remaining. The UTC offset is gotten by calling - * tzinfo.uctoffset(tzinfoarg). If that returns None, \0 is stored into + * buflen bytes remaining. If use_utc_designator is true, + * tzinfo.tzname(tzinfoarg) will be called, and if it returns "UTC", + * only "Z\0" will be added. Otherwise, the UTC offset is gotten by calling + * tzinfo.utcoffset(tzinfoarg). If that returns None, \0 is stored into * *buf, and that's all. Else the returned value is checked for sanity (an * integer in range), and if that's OK it's converted to an hours & minutes * string of the form @@ -1368,6 +1370,7 @@ static PyObject *delta_negative(PyDateTime_Delta *self); */ static int format_utcoffset(char *buf, size_t buflen, const char *sep, + int use_utc_designator, PyObject *tzinfo, PyObject *tzinfoarg) { PyObject *offset; @@ -1376,6 +1379,20 @@ format_utcoffset(char *buf, size_t buflen, const char *sep, assert(buflen >= 1); + if (use_utc_designator) { + PyObject* name = PyObject_CallMethod(tzinfo, "tzname", "O", tzinfoarg); + if (name == NULL) + return -1; + int tz_is_utc = (PyUnicode_Check(name) && + 0 == strcmp("UTC", PyUnicode_AsUTF8(name))); + Py_DECREF(name); + + if (tz_is_utc) { + PyOS_snprintf(buf, buflen, "Z"); + return 0; + } + } + offset = call_utcoffset(tzinfo, tzinfoarg); if (offset == NULL) return -1; @@ -1551,6 +1568,7 @@ wrap_strftime(PyObject *object, PyObject *format, PyObject *timetuple, if (format_utcoffset(buf, sizeof(buf), "", + 0, tzinfo, tzinfoarg) < 0) goto Done; @@ -4314,7 +4332,8 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) { char buf[100]; const char *timespec = NULL; - static char *keywords[] = {"timespec", NULL}; + int use_utc_designator = 0; + static char *keywords[] = {"timespec", "use_utc_designator", NULL}; PyObject *result; int us = TIME_GET_MICROSECOND(self); static const char *specs[][2] = { @@ -4326,7 +4345,8 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) }; size_t given_spec; - if (!PyArg_ParseTupleAndKeywords(args, kw, "|s:isoformat", keywords, ×pec)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "|sp:isoformat", keywords, + ×pec, &use_utc_designator)) return NULL; if (timespec == NULL || strcmp(timespec, "auto") == 0) { @@ -4365,8 +4385,8 @@ time_isoformat(PyDateTime_Time *self, PyObject *args, PyObject *kw) return result; /* We need to append the UTC offset. */ - if (format_utcoffset(buf, sizeof(buf), ":", self->tzinfo, - Py_None) < 0) { + if (format_utcoffset(buf, sizeof(buf), ":", use_utc_designator, + self->tzinfo, Py_None) < 0) { Py_DECREF(result); return NULL; } @@ -5548,7 +5568,8 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) { int sep = 'T'; char *timespec = NULL; - static char *keywords[] = {"sep", "timespec", NULL}; + int use_utc_designator = 0; + static char *keywords[] = {"sep", "timespec", "use_utc_designator", NULL}; char buffer[100]; PyObject *result = NULL; int us = DATE_GET_MICROSECOND(self); @@ -5561,7 +5582,8 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) }; size_t given_spec; - if (!PyArg_ParseTupleAndKeywords(args, kw, "|Cs:isoformat", keywords, &sep, ×pec)) + if (!PyArg_ParseTupleAndKeywords(args, kw, "|Csp:isoformat", keywords, + &sep, ×pec, &use_utc_designator)) return NULL; if (timespec == NULL || strcmp(timespec, "auto") == 0) { @@ -5601,8 +5623,8 @@ datetime_isoformat(PyDateTime_DateTime *self, PyObject *args, PyObject *kw) return result; /* We need to append the UTC offset. */ - if (format_utcoffset(buffer, sizeof(buffer), ":", self->tzinfo, - (PyObject *)self) < 0) { + if (format_utcoffset(buffer, sizeof(buffer), ":", use_utc_designator, + self->tzinfo, (PyObject *)self) < 0) { Py_DECREF(result); return NULL; }