Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 018d353

Browse files
authored
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.
1 parent c6ea897 commit 018d353

File tree

5 files changed

+127
-79
lines changed

5 files changed

+127
-79
lines changed

Doc/library/datetime.rst

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,16 +1071,20 @@ Instance methods:
10711071

10721072
If :attr:`.tzinfo` is ``None``, returns ``None``, else returns
10731073
``self.tzinfo.utcoffset(self)``, and raises an exception if the latter doesn't
1074-
return ``None``, or a :class:`timedelta` object representing a whole number of
1075-
minutes with magnitude less than one day.
1074+
return ``None`` or a :class:`timedelta` object with magnitude less than one day.
1075+
1076+
.. versionchanged:: 3.7
1077+
The UTC offset is not restricted to a whole number of minutes.
10761078

10771079

10781080
.. method:: datetime.dst()
10791081

10801082
If :attr:`.tzinfo` is ``None``, returns ``None``, else returns
10811083
``self.tzinfo.dst(self)``, and raises an exception if the latter doesn't return
1082-
``None``, or a :class:`timedelta` object representing a whole number of minutes
1083-
with magnitude less than one day.
1084+
``None`` or a :class:`timedelta` object with magnitude less than one day.
1085+
1086+
.. versionchanged:: 3.7
1087+
The DST offset is not restricted to a whole number of minutes.
10841088

10851089

10861090
.. method:: datetime.tzname()
@@ -1562,17 +1566,20 @@ Instance methods:
15621566

15631567
If :attr:`.tzinfo` is ``None``, returns ``None``, else returns
15641568
``self.tzinfo.utcoffset(None)``, and raises an exception if the latter doesn't
1565-
return ``None`` or a :class:`timedelta` object representing a whole number of
1566-
minutes with magnitude less than one day.
1569+
return ``None`` or a :class:`timedelta` object with magnitude less than one day.
1570+
1571+
.. versionchanged:: 3.7
1572+
The UTC offset is not restricted to a whole number of minutes.
15671573

15681574

15691575
.. method:: time.dst()
15701576

15711577
If :attr:`.tzinfo` is ``None``, returns ``None``, else returns
15721578
``self.tzinfo.dst(None)``, and raises an exception if the latter doesn't return
1573-
``None``, or a :class:`timedelta` object representing a whole number of minutes
1574-
with magnitude less than one day.
1579+
``None``, or a :class:`timedelta` object with magnitude less than one day.
15751580

1581+
.. versionchanged:: 3.7
1582+
The DST offset is not restricted to a whole number of minutes.
15761583

15771584
.. method:: time.tzname()
15781585

@@ -1641,13 +1648,14 @@ Example:
16411648

16421649
.. method:: tzinfo.utcoffset(dt)
16431650

1644-
Return offset of local time from UTC, in minutes east of UTC. If local time is
1651+
Return offset of local time from UTC, as a :class:`timedelta` object that is
1652+
positive east of UTC. If local time is
16451653
west of UTC, this should be negative. Note that this is intended to be the
16461654
total offset from UTC; for example, if a :class:`tzinfo` object represents both
16471655
time zone and DST adjustments, :meth:`utcoffset` should return their sum. If
16481656
the UTC offset isn't known, return ``None``. Else the value returned must be a
1649-
:class:`timedelta` object specifying a whole number of minutes in the range
1650-
-1439 to 1439 inclusive (1440 = 24\*60; the magnitude of the offset must be less
1657+
:class:`timedelta` object strictly between ``-timedelta(hours=24)`` and
1658+
``timedelta(hours=24)`` (the magnitude of the offset must be less
16511659
than one day). Most implementations of :meth:`utcoffset` will probably look
16521660
like one of these two::
16531661

@@ -1660,10 +1668,14 @@ Example:
16601668
The default implementation of :meth:`utcoffset` raises
16611669
:exc:`NotImplementedError`.
16621670

1671+
.. versionchanged:: 3.7
1672+
The UTC offset is not restricted to a whole number of minutes.
1673+
16631674

16641675
.. method:: tzinfo.dst(dt)
16651676

1666-
Return the daylight saving time (DST) adjustment, in minutes east of UTC, or
1677+
Return the daylight saving time (DST) adjustment, as a :class:`timedelta`
1678+
object or
16671679
``None`` if DST information isn't known. Return ``timedelta(0)`` if DST is not
16681680
in effect. If DST is in effect, return the offset as a :class:`timedelta` object
16691681
(see :meth:`utcoffset` for details). Note that DST offset, if applicable, has
@@ -1708,6 +1720,9 @@ Example:
17081720

17091721
The default implementation of :meth:`dst` raises :exc:`NotImplementedError`.
17101722

1723+
.. versionchanged:: 3.7
1724+
The DST offset is not restricted to a whole number of minutes.
1725+
17111726

17121727
.. method:: tzinfo.tzname(dt)
17131728

@@ -1887,21 +1902,27 @@ made to civil time.
18871902
The *offset* argument must be specified as a :class:`timedelta`
18881903
object representing the difference between the local time and UTC. It must
18891904
be strictly between ``-timedelta(hours=24)`` and
1890-
``timedelta(hours=24)`` and represent a whole number of minutes,
1891-
otherwise :exc:`ValueError` is raised.
1905+
``timedelta(hours=24)``, otherwise :exc:`ValueError` is raised.
18921906

18931907
The *name* argument is optional. If specified it must be a string that
18941908
will be used as the value returned by the :meth:`datetime.tzname` method.
18951909

18961910
.. versionadded:: 3.2
18971911

1912+
.. versionchanged:: 3.7
1913+
The UTC offset is not restricted to a whole number of minutes.
1914+
1915+
18981916
.. method:: timezone.utcoffset(dt)
18991917

19001918
Return the fixed value specified when the :class:`timezone` instance is
19011919
constructed. The *dt* argument is ignored. The return value is a
19021920
:class:`timedelta` instance equal to the difference between the
19031921
local time and UTC.
19041922

1923+
.. versionchanged:: 3.7
1924+
The UTC offset is not restricted to a whole number of minutes.
1925+
19051926
.. method:: timezone.tzname(dt)
19061927

19071928
Return the fixed value specified when the :class:`timezone` instance
@@ -2025,8 +2046,8 @@ format codes.
20252046
| | number, zero-padded on the | 999999 | |
20262047
| | left. | | |
20272048
+-----------+--------------------------------+------------------------+-------+
2028-
| ``%z`` | UTC offset in the form +HHMM | (empty), +0000, -0400, | \(6) |
2029-
| | or -HHMM (empty string if the | +1030 | |
2049+
| ``%z`` | UTC offset in the form | (empty), +0000, -0400, | \(6) |
2050+
| | ±HHMM[SS] (empty string if the | +1030 | |
20302051
| | object is naive). | | |
20312052
+-----------+--------------------------------+------------------------+-------+
20322053
| ``%Z`` | Time zone name (empty string | (empty), UTC, EST, CST | |
@@ -2139,12 +2160,19 @@ Notes:
21392160
For an aware object:
21402161

21412162
``%z``
2142-
:meth:`utcoffset` is transformed into a 5-character string of the form
2143-
+HHMM or -HHMM, where HH is a 2-digit string giving the number of UTC
2163+
:meth:`utcoffset` is transformed into a string of the form
2164+
±HHMM[SS[.uuuuuu]], where HH is a 2-digit string giving the number of UTC
21442165
offset hours, and MM is a 2-digit string giving the number of UTC offset
2145-
minutes. For example, if :meth:`utcoffset` returns
2146-
``timedelta(hours=-3, minutes=-30)``, ``%z`` is replaced with the string
2147-
``'-0330'``.
2166+
minutes, SS is a 2-digit string string giving the number of UTC offset
2167+
seconds and uuuuuu is a 2-digit string string giving the number of UTC
2168+
offset microseconds. The uuuuuu part is omitted when the offset is a
2169+
whole number of minutes and both the uuuuuu and the SS parts are omitted
2170+
when the offset is a whole number of minutes. For example, if
2171+
:meth:`utcoffset` returns ``timedelta(hours=-3, minutes=-30)``, ``%z`` is
2172+
replaced with the string ``'-0330'``.
2173+
2174+
.. versionchanged:: 3.7
2175+
The UTC offset is not restricted to a whole number of minutes.
21482176

21492177
``%Z``
21502178
If :meth:`tzname` returns ``None``, ``%Z`` is replaced by an empty

Lib/datetime.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,16 @@ def _wrap_strftime(object, format, timetuple):
206206
if offset.days < 0:
207207
offset = -offset
208208
sign = '-'
209-
h, m = divmod(offset, timedelta(hours=1))
210-
assert not m % timedelta(minutes=1), "whole minute"
211-
m //= timedelta(minutes=1)
212-
zreplace = '%c%02d%02d' % (sign, h, m)
209+
h, rest = divmod(offset, timedelta(hours=1))
210+
m, rest = divmod(rest, timedelta(minutes=1))
211+
s = rest.seconds
212+
u = offset.microseconds
213+
if u:
214+
zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u)
215+
elif s:
216+
zreplace = '%c%02d%02d%02d' % (sign, h, m, s)
217+
else:
218+
zreplace = '%c%02d%02d' % (sign, h, m)
213219
assert '%' not in zreplace
214220
newformat.append(zreplace)
215221
elif ch == 'Z':
@@ -241,7 +247,7 @@ def _check_tzname(name):
241247
# offset is what it returned.
242248
# If offset isn't None or timedelta, raises TypeError.
243249
# If offset is None, returns None.
244-
# Else offset is checked for being in range, and a whole # of minutes.
250+
# Else offset is checked for being in range.
245251
# If it is, its integer value is returned. Else ValueError is raised.
246252
def _check_utc_offset(name, offset):
247253
assert name in ("utcoffset", "dst")
@@ -250,9 +256,6 @@ def _check_utc_offset(name, offset):
250256
if not isinstance(offset, timedelta):
251257
raise TypeError("tzinfo.%s() must return None "
252258
"or timedelta, not '%s'" % (name, type(offset)))
253-
if offset.microseconds:
254-
raise ValueError("tzinfo.%s() must return a whole number "
255-
"of seconds, got %s" % (name, offset))
256259
if not -timedelta(1) < offset < timedelta(1):
257260
raise ValueError("%s()=%s, must be strictly between "
258261
"-timedelta(hours=24) and timedelta(hours=24)" %
@@ -960,11 +963,11 @@ def tzname(self, dt):
960963
raise NotImplementedError("tzinfo subclass must override tzname()")
961964

962965
def utcoffset(self, dt):
963-
"datetime -> minutes east of UTC (negative for west of UTC)"
966+
"datetime -> timedelta, positive for east of UTC, negative for west of UTC"
964967
raise NotImplementedError("tzinfo subclass must override utcoffset()")
965968

966969
def dst(self, dt):
967-
"""datetime -> DST offset in minutes east of UTC.
970+
"""datetime -> DST offset as timedelta, positive for east of UTC.
968971
969972
Return 0 if DST not in effect. utcoffset() must include the DST
970973
offset.
@@ -1262,8 +1265,8 @@ def __format__(self, fmt):
12621265
# Timezone functions
12631266

12641267
def utcoffset(self):
1265-
"""Return the timezone offset in minutes east of UTC (negative west of
1266-
UTC)."""
1268+
"""Return the timezone offset as timedelta, positive east of UTC
1269+
(negative west of UTC)."""
12671270
if self._tzinfo is None:
12681271
return None
12691272
offset = self._tzinfo.utcoffset(None)
@@ -1284,8 +1287,8 @@ def tzname(self):
12841287
return name
12851288

12861289
def dst(self):
1287-
"""Return 0 if DST is not in effect, or the DST offset (in minutes
1288-
eastward) if DST is in effect.
1290+
"""Return 0 if DST is not in effect, or the DST offset (as timedelta
1291+
positive eastward) if DST is in effect.
12891292
12901293
This is purely informational; the DST offset has already been added to
12911294
the UTC offset returned by utcoffset() if applicable, so there's no
@@ -1714,7 +1717,7 @@ def strptime(cls, date_string, format):
17141717
return _strptime._strptime_datetime(cls, date_string, format)
17151718

17161719
def utcoffset(self):
1717-
"""Return the timezone offset in minutes east of UTC (negative west of
1720+
"""Return the timezone offset as timedelta positive east of UTC (negative west of
17181721
UTC)."""
17191722
if self._tzinfo is None:
17201723
return None
@@ -1736,8 +1739,8 @@ def tzname(self):
17361739
return name
17371740

17381741
def dst(self):
1739-
"""Return 0 if DST is not in effect, or the DST offset (in minutes
1740-
eastward) if DST is in effect.
1742+
"""Return 0 if DST is not in effect, or the DST offset (as timedelta
1743+
positive eastward) if DST is in effect.
17411744
17421745
This is purely informational; the DST offset has already been added to
17431746
the UTC offset returned by utcoffset() if applicable, so there's no
@@ -1962,9 +1965,6 @@ def __new__(cls, offset, name=_Omitted):
19621965
raise ValueError("offset must be a timedelta "
19631966
"strictly between -timedelta(hours=24) and "
19641967
"timedelta(hours=24).")
1965-
if (offset.microseconds != 0 or offset.seconds % 60 != 0):
1966-
raise ValueError("offset must be a timedelta "
1967-
"representing a whole number of minutes")
19681968
return cls._create(offset, name)
19691969

19701970
@classmethod
@@ -2053,8 +2053,15 @@ def _name_from_offset(delta):
20532053
else:
20542054
sign = '+'
20552055
hours, rest = divmod(delta, timedelta(hours=1))
2056-
minutes = rest // timedelta(minutes=1)
2057-
return 'UTC{}{:02d}:{:02d}'.format(sign, hours, minutes)
2056+
minutes, rest = divmod(rest, timedelta(minutes=1))
2057+
seconds = rest.seconds
2058+
microseconds = rest.microseconds
2059+
if microseconds:
2060+
return (f'UTC{sign}{hours:02d}:{minutes:02d}:{seconds:02d}'
2061+
f'.{microseconds:06d}')
2062+
if seconds:
2063+
return f'UTC{sign}{hours:02d}:{minutes:02d}:{seconds:02d}'
2064+
return f'UTC{sign}{hours:02d}:{minutes:02d}'
20582065

20592066
timezone.utc = timezone._create(timedelta(0))
20602067
timezone.min = timezone._create(timezone._minoffset)

Lib/test/datetimetester.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,15 @@ def test_class_members(self):
255255
self.assertEqual(timezone.min.utcoffset(None), -limit)
256256
self.assertEqual(timezone.max.utcoffset(None), limit)
257257

258-
259258
def test_constructor(self):
260259
self.assertIs(timezone.utc, timezone(timedelta(0)))
261260
self.assertIsNot(timezone.utc, timezone(timedelta(0), 'UTC'))
262261
self.assertEqual(timezone.utc, timezone(timedelta(0), 'UTC'))
262+
for subminute in [timedelta(microseconds=1), timedelta(seconds=1)]:
263+
tz = timezone(subminute)
264+
self.assertNotEqual(tz.utcoffset(None) % timedelta(minutes=1), 0)
263265
# invalid offsets
264-
for invalid in [timedelta(microseconds=1), timedelta(1, 1),
265-
timedelta(seconds=1), timedelta(1), -timedelta(1)]:
266+
for invalid in [timedelta(1, 1), timedelta(1)]:
266267
self.assertRaises(ValueError, timezone, invalid)
267268
self.assertRaises(ValueError, timezone, -invalid)
268269

@@ -301,6 +302,15 @@ def test_tzname(self):
301302
self.assertEqual('UTC-00:01', timezone(timedelta(minutes=-1)).tzname(None))
302303
self.assertEqual('XYZ', timezone(-5 * HOUR, 'XYZ').tzname(None))
303304

305+
# Sub-minute offsets:
306+
self.assertEqual('UTC+01:06:40', timezone(timedelta(0, 4000)).tzname(None))
307+
self.assertEqual('UTC-01:06:40',
308+
timezone(-timedelta(0, 4000)).tzname(None))
309+
self.assertEqual('UTC+01:06:40.000001',
310+
timezone(timedelta(0, 4000, 1)).tzname(None))
311+
self.assertEqual('UTC-01:06:40.000001',
312+
timezone(-timedelta(0, 4000, 1)).tzname(None))
313+
304314
with self.assertRaises(TypeError): self.EST.tzname('')
305315
with self.assertRaises(TypeError): self.EST.tzname(5)
306316

@@ -2152,6 +2162,9 @@ def test_more_strftime(self):
21522162
t = self.theclass(2004, 12, 31, 6, 22, 33, 47)
21532163
self.assertEqual(t.strftime("%m %d %y %f %S %M %H %j"),
21542164
"12 31 04 000047 33 22 06 366")
2165+
tz = timezone(-timedelta(hours=2, seconds=33, microseconds=123))
2166+
t = t.replace(tzinfo=tz)
2167+
self.assertEqual(t.strftime("%z"), "-020033.000123")
21552168

21562169
def test_extract(self):
21572170
dt = self.theclass(2002, 3, 4, 18, 45, 3, 1234)
@@ -2717,8 +2730,8 @@ class C7(tzinfo):
27172730
def utcoffset(self, dt): return timedelta(microseconds=61)
27182731
def dst(self, dt): return timedelta(microseconds=-81)
27192732
t = cls(1, 1, 1, tzinfo=C7())
2720-
self.assertRaises(ValueError, t.utcoffset)
2721-
self.assertRaises(ValueError, t.dst)
2733+
self.assertEqual(t.utcoffset(), timedelta(microseconds=61))
2734+
self.assertEqual(t.dst(), timedelta(microseconds=-81))
27222735

27232736
def test_aware_compare(self):
27242737
cls = self.theclass
@@ -4297,7 +4310,6 @@ def test_vilnius_1941_toutc(self):
42974310
self.assertEqual(gdt.strftime("%c %Z"),
42984311
'Mon Jun 23 22:00:00 1941 UTC')
42994312

4300-
43014313
def test_constructors(self):
43024314
t = time(0, fold=1)
43034315
dt = datetime(1, 1, 1, fold=1)
@@ -4372,7 +4384,6 @@ def test_fromtimestamp_lord_howe(self):
43724384
self.assertEqual(t0.fold, 0)
43734385
self.assertEqual(t1.fold, 1)
43744386

4375-
43764387
@support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0')
43774388
def test_timestamp(self):
43784389
dt0 = datetime(2014, 11, 2, 1, 30)
@@ -4390,7 +4401,6 @@ def test_timestamp_lord_howe(self):
43904401
s1 = t.replace(fold=1).timestamp()
43914402
self.assertEqual(s0 + 1800, s1)
43924403

4393-
43944404
@support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0')
43954405
def test_astimezone(self):
43964406
dt0 = datetime(2014, 11, 2, 1, 30)
@@ -4406,7 +4416,6 @@ def test_astimezone(self):
44064416
self.assertEqual(adt0.fold, 0)
44074417
self.assertEqual(adt1.fold, 0)
44084418

4409-
44104419
def test_pickle_fold(self):
44114420
t = time(fold=1)
44124421
dt = datetime(1, 1, 1, fold=1)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support tzinfo objects with sub-minute offsets.

0 commit comments

Comments
 (0)