From 018d353c1c8c87767d2335cd884017c2ce12e045 Mon Sep 17 00:00:00 2001 From: Alexander Belopolsky Date: Mon, 31 Jul 2017 10:26:50 -0400 Subject: Closes issue bpo-5288: Allow tzinfo objects with sub-minute offsets. (#2896) * Closes issue bpo-5288: Allow tzinfo objects with sub-minute offsets. * bpo-5288: Implemented %z formatting of sub-minute offsets. * bpo-5288: Removed mentions of the whole minute limitation on TZ offsets. * bpo-5288: Removed one more mention of the whole minute limitation. Thanks @csabella! * Fix a formatting error in the docs * Addressed review comments. Thanks, @haypo. --- Doc/library/datetime.rst | 70 +++++++++++++++------- Lib/datetime.py | 51 +++++++++------- Lib/test/datetimetester.py | 27 ++++++--- .../2017-07-26-13-18-29.bpo-5288.o_xEGj.rst | 1 + Modules/_datetimemodule.c | 57 +++++++++--------- 5 files changed, 127 insertions(+), 79 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2017-07-26-13-18-29.bpo-5288.o_xEGj.rst diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst index 3880c2e..55be869 100644 --- a/Doc/library/datetime.rst +++ b/Doc/library/datetime.rst @@ -1071,16 +1071,20 @@ Instance methods: If :attr:`.tzinfo` is ``None``, returns ``None``, else returns ``self.tzinfo.utcoffset(self)``, and raises an exception if the latter doesn't - return ``None``, or a :class:`timedelta` object representing a whole number of - minutes with magnitude less than one day. + return ``None`` or a :class:`timedelta` object with magnitude less than one day. + + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. .. method:: datetime.dst() If :attr:`.tzinfo` is ``None``, returns ``None``, else returns ``self.tzinfo.dst(self)``, and raises an exception if the latter doesn't return - ``None``, or a :class:`timedelta` object representing a whole number of minutes - with magnitude less than one day. + ``None`` or a :class:`timedelta` object with magnitude less than one day. + + .. versionchanged:: 3.7 + The DST offset is not restricted to a whole number of minutes. .. method:: datetime.tzname() @@ -1562,17 +1566,20 @@ Instance methods: If :attr:`.tzinfo` is ``None``, returns ``None``, else returns ``self.tzinfo.utcoffset(None)``, and raises an exception if the latter doesn't - return ``None`` or a :class:`timedelta` object representing a whole number of - minutes with magnitude less than one day. + return ``None`` or a :class:`timedelta` object with magnitude less than one day. + + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. .. method:: time.dst() If :attr:`.tzinfo` is ``None``, returns ``None``, else returns ``self.tzinfo.dst(None)``, and raises an exception if the latter doesn't return - ``None``, or a :class:`timedelta` object representing a whole number of minutes - with magnitude less than one day. + ``None``, or a :class:`timedelta` object with magnitude less than one day. + .. versionchanged:: 3.7 + The DST offset is not restricted to a whole number of minutes. .. method:: time.tzname() @@ -1641,13 +1648,14 @@ Example: .. method:: tzinfo.utcoffset(dt) - Return offset of local time from UTC, in minutes east of UTC. If local time is + Return offset of local time from UTC, as a :class:`timedelta` object that is + positive east of UTC. If local time is west of UTC, this should be negative. Note that this is intended to be the total offset from UTC; for example, if a :class:`tzinfo` object represents both time zone and DST adjustments, :meth:`utcoffset` should return their sum. If the UTC offset isn't known, return ``None``. Else the value returned must be a - :class:`timedelta` object specifying a whole number of minutes in the range - -1439 to 1439 inclusive (1440 = 24\*60; the magnitude of the offset must be less + :class:`timedelta` object strictly between ``-timedelta(hours=24)`` and + ``timedelta(hours=24)`` (the magnitude of the offset must be less than one day). Most implementations of :meth:`utcoffset` will probably look like one of these two:: @@ -1660,10 +1668,14 @@ Example: The default implementation of :meth:`utcoffset` raises :exc:`NotImplementedError`. + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. + .. method:: tzinfo.dst(dt) - Return the daylight saving time (DST) adjustment, in minutes east of UTC, or + Return the daylight saving time (DST) adjustment, as a :class:`timedelta` + object or ``None`` if DST information isn't known. Return ``timedelta(0)`` if DST is not in effect. If DST is in effect, return the offset as a :class:`timedelta` object (see :meth:`utcoffset` for details). Note that DST offset, if applicable, has @@ -1708,6 +1720,9 @@ Example: The default implementation of :meth:`dst` raises :exc:`NotImplementedError`. + .. versionchanged:: 3.7 + The DST offset is not restricted to a whole number of minutes. + .. method:: tzinfo.tzname(dt) @@ -1887,14 +1902,17 @@ made to civil time. The *offset* argument must be specified as a :class:`timedelta` object representing the difference between the local time and UTC. It must be strictly between ``-timedelta(hours=24)`` and - ``timedelta(hours=24)`` and represent a whole number of minutes, - otherwise :exc:`ValueError` is raised. + ``timedelta(hours=24)``, otherwise :exc:`ValueError` is raised. The *name* argument is optional. If specified it must be a string that will be used as the value returned by the :meth:`datetime.tzname` method. .. versionadded:: 3.2 + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. + + .. method:: timezone.utcoffset(dt) Return the fixed value specified when the :class:`timezone` instance is @@ -1902,6 +1920,9 @@ made to civil time. :class:`timedelta` instance equal to the difference between the local time and UTC. + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. + .. method:: timezone.tzname(dt) Return the fixed value specified when the :class:`timezone` instance @@ -2025,8 +2046,8 @@ format codes. | | number, zero-padded on the | 999999 | | | | left. | | | +-----------+--------------------------------+------------------------+-------+ -| ``%z`` | UTC offset in the form +HHMM | (empty), +0000, -0400, | \(6) | -| | or -HHMM (empty string if the | +1030 | | +| ``%z`` | UTC offset in the form | (empty), +0000, -0400, | \(6) | +| | ±HHMM[SS] (empty string if the | +1030 | | | | object is naive). | | | +-----------+--------------------------------+------------------------+-------+ | ``%Z`` | Time zone name (empty string | (empty), UTC, EST, CST | | @@ -2139,12 +2160,19 @@ Notes: For an aware object: ``%z`` - :meth:`utcoffset` is transformed into a 5-character string of the form - +HHMM or -HHMM, where HH is a 2-digit string giving the number of UTC + :meth:`utcoffset` is transformed into a string of the form + ±HHMM[SS[.uuuuuu]], where HH is a 2-digit string giving the number of UTC offset hours, and MM is a 2-digit string giving the number of UTC offset - minutes. For example, if :meth:`utcoffset` returns - ``timedelta(hours=-3, minutes=-30)``, ``%z`` is replaced with the string - ``'-0330'``. + minutes, SS is a 2-digit string string giving the number of UTC offset + seconds and uuuuuu is a 2-digit string string giving the number of UTC + offset microseconds. The uuuuuu part is omitted when the offset is a + whole number of minutes and both the uuuuuu and the SS parts are omitted + when the offset is a whole number of minutes. For example, if + :meth:`utcoffset` returns ``timedelta(hours=-3, minutes=-30)``, ``%z`` is + replaced with the string ``'-0330'``. + + .. versionchanged:: 3.7 + The UTC offset is not restricted to a whole number of minutes. ``%Z`` If :meth:`tzname` returns ``None``, ``%Z`` is replaced by an empty diff --git a/Lib/datetime.py b/Lib/datetime.py index 76a6f95..2f03847 100644 --- a/Lib/datetime.py +++ b/Lib/datetime.py @@ -206,10 +206,16 @@ def _wrap_strftime(object, format, timetuple): if offset.days < 0: offset = -offset sign = '-' - h, m = divmod(offset, timedelta(hours=1)) - assert not m % timedelta(minutes=1), "whole minute" - m //= timedelta(minutes=1) - zreplace = '%c%02d%02d' % (sign, h, m) + h, rest = divmod(offset, timedelta(hours=1)) + m, rest = divmod(rest, timedelta(minutes=1)) + s = rest.seconds + u = offset.microseconds + if u: + zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u) + elif s: + zreplace = '%c%02d%02d%02d' % (sign, h, m, s) + else: + zreplace = '%c%02d%02d' % (sign, h, m) assert '%' not in zreplace newformat.append(zreplace) elif ch == 'Z': @@ -241,7 +247,7 @@ def _check_tzname(name): # offset is what it returned. # If offset isn't None or timedelta, raises TypeError. # If offset is None, returns None. -# Else offset is checked for being in range, and a whole # of minutes. +# Else offset is checked for being in range. # If it is, its integer value is returned. Else ValueError is raised. def _check_utc_offset(name, offset): assert name in ("utcoffset", "dst") @@ -250,9 +256,6 @@ def _check_utc_offset(name, offset): if not isinstance(offset, timedelta): raise TypeError("tzinfo.%s() must return None " "or timedelta, not '%s'" % (name, type(offset))) - if offset.microseconds: - raise ValueError("tzinfo.%s() must return a whole number " - "of seconds, got %s" % (name, offset)) if not -timedelta(1) < offset < timedelta(1): raise ValueError("%s()=%s, must be strictly between " "-timedelta(hours=24) and timedelta(hours=24)" % @@ -960,11 +963,11 @@ class tzinfo: raise NotImplementedError("tzinfo subclass must override tzname()") def utcoffset(self, dt): - "datetime -> minutes east of UTC (negative for west of UTC)" + "datetime -> timedelta, positive for east of UTC, negative for west of UTC" raise NotImplementedError("tzinfo subclass must override utcoffset()") def dst(self, dt): - """datetime -> DST offset in minutes east of UTC. + """datetime -> DST offset as timedelta, positive for east of UTC. Return 0 if DST not in effect. utcoffset() must include the DST offset. @@ -1262,8 +1265,8 @@ class time: # Timezone functions def utcoffset(self): - """Return the timezone offset in minutes east of UTC (negative west of - UTC).""" + """Return the timezone offset as timedelta, positive east of UTC + (negative west of UTC).""" if self._tzinfo is None: return None offset = self._tzinfo.utcoffset(None) @@ -1284,8 +1287,8 @@ class time: return name def dst(self): - """Return 0 if DST is not in effect, or the DST offset (in minutes - eastward) if DST is in effect. + """Return 0 if DST is not in effect, or the DST offset (as timedelta + positive eastward) if DST is in effect. This is purely informational; the DST offset has already been added to the UTC offset returned by utcoffset() if applicable, so there's no @@ -1714,7 +1717,7 @@ class datetime(date): return _strptime._strptime_datetime(cls, date_string, format) def utcoffset(self): - """Return the timezone offset in minutes east of UTC (negative west of + """Return the timezone offset as timedelta positive east of UTC (negative west of UTC).""" if self._tzinfo is None: return None @@ -1736,8 +1739,8 @@ class datetime(date): return name def dst(self): - """Return 0 if DST is not in effect, or the DST offset (in minutes - eastward) if DST is in effect. + """Return 0 if DST is not in effect, or the DST offset (as timedelta + positive eastward) if DST is in effect. This is purely informational; the DST offset has already been added to the UTC offset returned by utcoffset() if applicable, so there's no @@ -1962,9 +1965,6 @@ class timezone(tzinfo): raise ValueError("offset must be a timedelta " "strictly between -timedelta(hours=24) and " "timedelta(hours=24).") - if (offset.microseconds != 0 or offset.seconds % 60 != 0): - raise ValueError("offset must be a timedelta " - "representing a whole number of minutes") return cls._create(offset, name) @classmethod @@ -2053,8 +2053,15 @@ class timezone(tzinfo): else: sign = '+' hours, rest = divmod(delta, timedelta(hours=1)) - minutes = rest // timedelta(minutes=1) - return 'UTC{}{:02d}:{:02d}'.format(sign, hours, minutes) + minutes, rest = divmod(rest, timedelta(minutes=1)) + seconds = rest.seconds + microseconds = rest.microseconds + if microseconds: + return (f'UTC{sign}{hours:02d}:{minutes:02d}:{seconds:02d}' + f'.{microseconds:06d}') + if seconds: + return f'UTC{sign}{hours:02d}:{minutes:02d}:{seconds:02d}' + return f'UTC{sign}{hours:02d}:{minutes:02d}' timezone.utc = timezone._create(timedelta(0)) timezone.min = timezone._create(timezone._minoffset) diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py index 2200888..29b70e1 100644 --- a/Lib/test/datetimetester.py +++ b/Lib/test/datetimetester.py @@ -255,14 +255,15 @@ class TestTimeZone(unittest.TestCase): self.assertEqual(timezone.min.utcoffset(None), -limit) self.assertEqual(timezone.max.utcoffset(None), limit) - def test_constructor(self): self.assertIs(timezone.utc, timezone(timedelta(0))) self.assertIsNot(timezone.utc, timezone(timedelta(0), 'UTC')) self.assertEqual(timezone.utc, timezone(timedelta(0), 'UTC')) + for subminute in [timedelta(microseconds=1), timedelta(seconds=1)]: + tz = timezone(subminute) + self.assertNotEqual(tz.utcoffset(None) % timedelta(minutes=1), 0) # invalid offsets - for invalid in [timedelta(microseconds=1), timedelta(1, 1), - timedelta(seconds=1), timedelta(1), -timedelta(1)]: + for invalid in [timedelta(1, 1), timedelta(1)]: self.assertRaises(ValueError, timezone, invalid) self.assertRaises(ValueError, timezone, -invalid) @@ -301,6 +302,15 @@ class TestTimeZone(unittest.TestCase): self.assertEqual('UTC-00:01', timezone(timedelta(minutes=-1)).tzname(None)) self.assertEqual('XYZ', timezone(-5 * HOUR, 'XYZ').tzname(None)) + # Sub-minute offsets: + self.assertEqual('UTC+01:06:40', timezone(timedelta(0, 4000)).tzname(None)) + self.assertEqual('UTC-01:06:40', + timezone(-timedelta(0, 4000)).tzname(None)) + self.assertEqual('UTC+01:06:40.000001', + timezone(timedelta(0, 4000, 1)).tzname(None)) + self.assertEqual('UTC-01:06:40.000001', + timezone(-timedelta(0, 4000, 1)).tzname(None)) + with self.assertRaises(TypeError): self.EST.tzname('') with self.assertRaises(TypeError): self.EST.tzname(5) @@ -2152,6 +2162,9 @@ class TestDateTime(TestDate): t = self.theclass(2004, 12, 31, 6, 22, 33, 47) self.assertEqual(t.strftime("%m %d %y %f %S %M %H %j"), "12 31 04 000047 33 22 06 366") + tz = timezone(-timedelta(hours=2, seconds=33, microseconds=123)) + t = t.replace(tzinfo=tz) + self.assertEqual(t.strftime("%z"), "-020033.000123") def test_extract(self): dt = self.theclass(2002, 3, 4, 18, 45, 3, 1234) @@ -2717,8 +2730,8 @@ class TZInfoBase: def utcoffset(self, dt): return timedelta(microseconds=61) def dst(self, dt): return timedelta(microseconds=-81) t = cls(1, 1, 1, tzinfo=C7()) - self.assertRaises(ValueError, t.utcoffset) - self.assertRaises(ValueError, t.dst) + self.assertEqual(t.utcoffset(), timedelta(microseconds=61)) + self.assertEqual(t.dst(), timedelta(microseconds=-81)) def test_aware_compare(self): cls = self.theclass @@ -4297,7 +4310,6 @@ class TestLocalTimeDisambiguation(unittest.TestCase): self.assertEqual(gdt.strftime("%c %Z"), 'Mon Jun 23 22:00:00 1941 UTC') - def test_constructors(self): t = time(0, fold=1) dt = datetime(1, 1, 1, fold=1) @@ -4372,7 +4384,6 @@ class TestLocalTimeDisambiguation(unittest.TestCase): self.assertEqual(t0.fold, 0) self.assertEqual(t1.fold, 1) - @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') def test_timestamp(self): dt0 = datetime(2014, 11, 2, 1, 30) @@ -4390,7 +4401,6 @@ class TestLocalTimeDisambiguation(unittest.TestCase): s1 = t.replace(fold=1).timestamp() self.assertEqual(s0 + 1800, s1) - @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') def test_astimezone(self): dt0 = datetime(2014, 11, 2, 1, 30) @@ -4406,7 +4416,6 @@ class TestLocalTimeDisambiguation(unittest.TestCase): self.assertEqual(adt0.fold, 0) self.assertEqual(adt1.fold, 0) - def test_pickle_fold(self): t = time(fold=1) dt = datetime(1, 1, 1, fold=1) diff --git a/Misc/NEWS.d/next/Library/2017-07-26-13-18-29.bpo-5288.o_xEGj.rst b/Misc/NEWS.d/next/Library/2017-07-26-13-18-29.bpo-5288.o_xEGj.rst new file mode 100644 index 0000000..a7eaa06 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2017-07-26-13-18-29.bpo-5288.o_xEGj.rst @@ -0,0 +1 @@ +Support tzinfo objects with sub-minute offsets. diff --git a/Modules/_datetimemodule.c b/Modules/_datetimemodule.c index 28805d1..1b68ff3 100644 --- a/Modules/_datetimemodule.c +++ b/Modules/_datetimemodule.c @@ -859,12 +859,6 @@ new_timezone(PyObject *offset, PyObject *name) Py_INCREF(PyDateTime_TimeZone_UTC); return PyDateTime_TimeZone_UTC; } - if (GET_TD_MICROSECONDS(offset) != 0 || GET_TD_SECONDS(offset) % 60 != 0) { - PyErr_Format(PyExc_ValueError, "offset must be a timedelta" - " representing a whole number of minutes," - " not %R.", offset); - return NULL; - } if ((GET_TD_DAYS(offset) == -1 && GET_TD_SECONDS(offset) == 0) || GET_TD_DAYS(offset) < -1 || GET_TD_DAYS(offset) >= 1) { PyErr_Format(PyExc_ValueError, "offset must be a timedelta" @@ -935,12 +929,6 @@ call_tzinfo_method(PyObject *tzinfo, const char *name, PyObject *tzinfoarg) if (offset == Py_None || offset == NULL) return offset; if (PyDelta_Check(offset)) { - if (GET_TD_MICROSECONDS(offset) != 0) { - Py_DECREF(offset); - PyErr_Format(PyExc_ValueError, "offset must be a timedelta" - " representing a whole number of seconds"); - return NULL; - } if ((GET_TD_DAYS(offset) == -1 && GET_TD_SECONDS(offset) == 0) || GET_TD_DAYS(offset) < -1 || GET_TD_DAYS(offset) >= 1) { Py_DECREF(offset); @@ -966,9 +954,9 @@ call_tzinfo_method(PyObject *tzinfo, const char *name, PyObject *tzinfoarg) * result. tzinfo must be an instance of the tzinfo class. If utcoffset() * returns None, call_utcoffset returns 0 and sets *none to 1. If uctoffset() * doesn't return None or timedelta, TypeError is raised and this returns -1. - * If utcoffset() returns an invalid timedelta (out of range, or not a whole - * # of minutes), ValueError is raised and this returns -1. Else *none is - * set to 0 and the offset is returned (as int # of minutes east of UTC). + * If utcoffset() returns an out of range timedelta, + * ValueError is raised and this returns -1. Else *none is + * set to 0 and the offset is returned (as timedelta, positive east of UTC). */ static PyObject * call_utcoffset(PyObject *tzinfo, PyObject *tzinfoarg) @@ -979,10 +967,10 @@ call_utcoffset(PyObject *tzinfo, PyObject *tzinfoarg) /* Call tzinfo.dst(tzinfoarg), and extract an integer from the * result. tzinfo must be an instance of the tzinfo class. If dst() * returns None, call_dst returns 0 and sets *none to 1. If dst() - & doesn't return None or timedelta, TypeError is raised and this + * doesn't return None or timedelta, TypeError is raised and this * returns -1. If dst() returns an invalid timedelta for a UTC offset, * ValueError is raised and this returns -1. Else *none is set to 0 and - * the offset is returned (as an int # of minutes east of UTC). + * the offset is returned (as timedelta, positive east of UTC). */ static PyObject * call_dst(PyObject *tzinfo, PyObject *tzinfoarg) @@ -1100,13 +1088,13 @@ format_ctime(PyDateTime_Date *date, int hours, int minutes, int seconds) static PyObject *delta_negative(PyDateTime_Delta *self); -/* Add an hours & minutes UTC offset string to buf. buf has no more than +/* 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 * *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 - * sign HH sep MM + * sign HH sep MM [sep SS [. UUUUUU]] * Returns 0 if everything is OK. If the return value from utcoffset() is * bogus, an appropriate exception is set and -1 is returned. */ @@ -1115,7 +1103,7 @@ format_utcoffset(char *buf, size_t buflen, const char *sep, PyObject *tzinfo, PyObject *tzinfoarg) { PyObject *offset; - int hours, minutes, seconds; + int hours, minutes, seconds, microseconds; char sign; assert(buflen >= 1); @@ -1139,15 +1127,22 @@ format_utcoffset(char *buf, size_t buflen, const char *sep, sign = '+'; } /* Offset is not negative here. */ + microseconds = GET_TD_MICROSECONDS(offset); seconds = GET_TD_SECONDS(offset); Py_DECREF(offset); minutes = divmod(seconds, 60, &seconds); hours = divmod(minutes, 60, &minutes); - if (seconds == 0) - PyOS_snprintf(buf, buflen, "%c%02d%s%02d", sign, hours, sep, minutes); - else + if (microseconds) { + PyOS_snprintf(buf, buflen, "%c%02d%s%02d%s%02d.%06d", sign, + hours, sep, minutes, sep, seconds, microseconds); + return 0; + } + if (seconds) { PyOS_snprintf(buf, buflen, "%c%02d%s%02d%s%02d", sign, hours, sep, minutes, sep, seconds); + return 0; + } + PyOS_snprintf(buf, buflen, "%c%02d%s%02d", sign, hours, sep, minutes); return 0; } @@ -3241,7 +3236,7 @@ static PyMethodDef tzinfo_methods[] = { "values indicating West of UTC")}, {"dst", (PyCFunction)tzinfo_dst, METH_O, - PyDoc_STR("datetime -> DST offset in minutes east of UTC.")}, + PyDoc_STR("datetime -> DST offset as timedelta positive east of UTC.")}, {"fromutc", (PyCFunction)tzinfo_fromutc, METH_O, PyDoc_STR("datetime in UTC -> datetime in local time.")}, @@ -3375,7 +3370,7 @@ timezone_repr(PyDateTime_TimeZone *self) static PyObject * timezone_str(PyDateTime_TimeZone *self) { - int hours, minutes, seconds; + int hours, minutes, seconds, microseconds; PyObject *offset; char sign; @@ -3401,12 +3396,20 @@ timezone_str(PyDateTime_TimeZone *self) Py_INCREF(offset); } /* Offset is not negative here. */ + microseconds = GET_TD_MICROSECONDS(offset); seconds = GET_TD_SECONDS(offset); Py_DECREF(offset); minutes = divmod(seconds, 60, &seconds); hours = divmod(minutes, 60, &minutes); - /* XXX ignore sub-minute data, currently not allowed. */ - assert(seconds == 0); + if (microseconds != 0) { + return PyUnicode_FromFormat("UTC%c%02d:%02d:%02d.%06d", + sign, hours, minutes, + seconds, microseconds); + } + if (seconds != 0) { + return PyUnicode_FromFormat("UTC%c%02d:%02d:%02d", + sign, hours, minutes, seconds); + } return PyUnicode_FromFormat("UTC%c%02d:%02d", sign, hours, minutes); } -- cgit v0.12