Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes issue bpo-5288: Allow tzinfo objects with sub-minute offsets. #2896

Merged
merged 6 commits into from
Jul 31, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 44 additions & 18 deletions Doc/library/datetime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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::

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -1887,21 +1902,27 @@ 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
constructed. The *dt* argument is ignored. The return value is a
: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
Expand Down Expand Up @@ -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 | |
Expand Down Expand Up @@ -2139,13 +2160,18 @@ 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], 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, and SS is a string giving the number of UTC offset
seconds. The SS part is 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to be more explicit: mention that optional support for seconds was added, since microseconds are not supported here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, microseconds are supported here. Will larigy.


``%Z``
If :meth:`tzname` returns ``None``, ``%Z`` is replaced by an empty
string. Otherwise ``%Z`` is replaced by the returned value, which must
Expand Down
50 changes: 29 additions & 21 deletions Lib/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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")
Expand All @@ -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)" %
Expand Down Expand Up @@ -960,11 +963,11 @@ def tzname(self, dt):
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.
Expand Down Expand Up @@ -1262,8 +1265,8 @@ def __format__(self, fmt):
# 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)
Expand All @@ -1284,8 +1287,8 @@ def tzname(self):
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
Expand Down Expand Up @@ -1714,7 +1717,7 @@ def strptime(cls, date_string, format):
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
Expand All @@ -1736,8 +1739,8 @@ def tzname(self):
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
Expand Down Expand Up @@ -1962,9 +1965,6 @@ def __new__(cls, offset, name=_Omitted):
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
Expand Down Expand Up @@ -2053,7 +2053,15 @@ def _name_from_offset(delta):
else:
sign = '+'
hours, rest = divmod(delta, timedelta(hours=1))
minutes = rest // timedelta(minutes=1)
minutes, rest = divmod(rest, timedelta(minutes=1))
seconds = rest.seconds
microseconds = rest.microseconds
if microseconds:
return 'UTC{}{:02d}:{:02d}:{:02d}.{:06d}'.format(sign,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may use f-string here ;-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

hours, minutes, seconds, microseconds)
if seconds:
return 'UTC{}{:02d}:{:02d}:{:02d}'.format(sign, hours,
minutes, seconds)
return 'UTC{}{:02d}:{:02d}'.format(sign, hours, minutes)

timezone.utc = timezone._create(timedelta(0))
Expand Down
27 changes: 18 additions & 9 deletions Lib/test/datetimetester.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,15 @@ def test_class_members(self):
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)

Expand Down Expand Up @@ -301,6 +302,15 @@ def test_tzname(self):
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)

Expand Down Expand Up @@ -2152,6 +2162,9 @@ def test_more_strftime(self):
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)
Expand Down Expand Up @@ -2717,8 +2730,8 @@ class C7(tzinfo):
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
Expand Down Expand Up @@ -4297,7 +4310,6 @@ def test_vilnius_1941_toutc(self):
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)
Expand Down Expand Up @@ -4372,7 +4384,6 @@ def test_fromtimestamp_lord_howe(self):
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)
Expand All @@ -4390,7 +4401,6 @@ def test_timestamp_lord_howe(self):
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)
Expand All @@ -4406,7 +4416,6 @@ def test_astimezone(self):
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support tzinfo objects with sub-minute offsets.
Loading