Repository: dateutil/dateutil (Python)

Issue:
tzfile does not support non-integer minute offsets
Discovered this bug in #565 in the test for [Monrovia](https://www.timeanddate.com/time/zone/liberia/monrovia). I'm guessing that the problem is that before Python 3.6-ish, `datetime` didn't support this at all:

```python
>>> from dateutil import tz
>>> from datetime import *
>>> tzi = tz.tzoffset(None, timedelta(hours=12, seconds=30))
>>> datetime(2000, 1, 1, tzinfo=tzi)
datetime.datetime(2000, 1, 1, 0, 0, tzinfo=tzoffset(None, 43230))
>>> datetime(2000, 1, 1, tzinfo=tzi).utcoffset()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: tzinfo.utcoffset() must return a whole number of minutes
```

This should definitely be fixed in all versions of Python that support non-integer offsets, and the main question is what to do in the *other* cases. The options we have are:

1. Continue the current behavior of silently truncating to the nearest minute
2. Truncate the offset and add a warning in the *constructor* when a timezone with a non-integer number of minutes is constructed on a platform that doesn't support it.
3. Truncate the offset and add a warning in *utcoffset* or something when a platform doesn't support it.
4. Don't truncate at all and let `.utcoffset()` throw errors on platforms that don't support the non-truncated version.

I suspect that this affects a vanishingly small number of people, so my preferred solution is the one that has minimal impact on those people who are using timezones with integer number of minute offsets and people using a recent version of Python. I imagine that rules out a check in `utcoffset`. The lowest-cost thing to do is the fourth one, but that's the least friendly way. I'm thinking 2 is the right way to go.

Ideally we'll merge #580 and use that framework to distinguish between the different approaches.

This issue is currently blocking the merge of #565.


Hints:
One thing to note is that the Olson database does not support sub-second offsets, per the [theory file](http://web.cs.ucla.edu/~eggert/tz/theory.html).
I'm looking into this -- going with method 2 as suggested in original comment

Patch:
diff --git a/dateutil/tz/tz.py b/dateutil/tz/tz.py
--- a/dateutil/tz/tz.py
+++ b/dateutil/tz/tz.py
@@ -29,6 +29,9 @@
 except ImportError:
     tzwin = tzwinlocal = None
 
+# For warning about rounding tzinfo
+from warnings import warn
+
 ZERO = datetime.timedelta(0)
 EPOCH = datetime.datetime.utcfromtimestamp(0)
 EPOCHORDINAL = EPOCH.toordinal()
@@ -138,7 +141,8 @@ def __init__(self, name, offset):
             offset = offset.total_seconds()
         except (TypeError, AttributeError):
             pass
-        self._offset = datetime.timedelta(seconds=offset)
+
+        self._offset = datetime.timedelta(seconds=_get_supported_offset(offset))
 
     def utcoffset(self, dt):
         return self._offset
@@ -601,10 +605,7 @@ def _read_tzfile(self, fileobj):
         out.ttinfo_list = []
         for i in range(typecnt):
             gmtoff, isdst, abbrind = ttinfo[i]
-            # Round to full-minutes if that's not the case. Python's
-            # datetime doesn't accept sub-minute timezones. Check
-            # http://python.org/sf/1447945 for some information.
-            gmtoff = 60 * ((gmtoff + 30) // 60)
+            gmtoff = _get_supported_offset(gmtoff)
             tti = _ttinfo()
             tti.offset = gmtoff
             tti.dstoffset = datetime.timedelta(0)
@@ -1769,6 +1770,20 @@ def _datetime_to_timestamp(dt):
     return (dt.replace(tzinfo=None) - EPOCH).total_seconds()
 
 
+if sys.version_info >= (3, 6):
+    def _get_supported_offset(second_offset):
+        return second_offset
+else:
+    def _get_supported_offset(second_offset):
+        # For python pre-3.6, round to full-minutes if that's not the case.
+        # Python's datetime doesn't accept sub-minute timezones. Check
+        # http://python.org/sf/1447945 or https://bugs.python.org/issue5288
+        # for some information.
+        old_offset = second_offset
+        calculated_offset = 60 * ((second_offset + 30) // 60)
+        return calculated_offset
+
+
 class _ContextWrapper(object):
     """
     Class for wrapping contexts so that they are passed through in a


Test patch:
diff --git a/dateutil/test/test_tz.py b/dateutil/test/test_tz.py
--- a/dateutil/test/test_tz.py
+++ b/dateutil/test/test_tz.py
@@ -156,6 +156,8 @@
 EST_TUPLE = ('EST', timedelta(hours=-5), timedelta(hours=0))
 EDT_TUPLE = ('EDT', timedelta(hours=-4), timedelta(hours=1))
 
+SUPPORTS_SUB_MINUTE_OFFSETS = sys.version_info >= (3, 6)
+
 
 ###
 # Helper functions
@@ -760,6 +762,25 @@ def test_tzoffset_singleton(args):
 
     assert tz1 is tz2
 
+
+@pytest.mark.tzoffset
+@pytest.mark.skipif(not SUPPORTS_SUB_MINUTE_OFFSETS,
+                    reason='Sub-minute offsets not supported')
+def test_tzoffset_sub_minute():
+    delta = timedelta(hours=12, seconds=30)
+    test_datetime = datetime(2000, 1, 1, tzinfo=tz.tzoffset(None, delta))
+    assert test_datetime.utcoffset() == delta
+
+
+@pytest.mark.tzoffset
+@pytest.mark.skipif(SUPPORTS_SUB_MINUTE_OFFSETS,
+                    reason='Sub-minute offsets supported')
+def test_tzoffset_sub_minute_rounding():
+    delta = timedelta(hours=12, seconds=30)
+    test_date = datetime(2000, 1, 1, tzinfo=tz.tzoffset(None, delta))
+    assert test_date.utcoffset() == timedelta(hours=12, minutes=1)
+
+
 @pytest.mark.tzlocal
 class TzLocalTest(unittest.TestCase):
     def testEquality(self):
@@ -1908,11 +1929,10 @@ def testFilestreamWithNameRepr(self):
         tzc = tz.tzfile(fileobj)
         self.assertEqual(repr(tzc), 'tzfile(' + repr('foo') + ')')
 
-    def testRoundNonFullMinutes(self):
-        # This timezone has an offset of 5992 seconds in 1900-01-01.
-        tzc = tz.tzfile(BytesIO(base64.b64decode(EUROPE_HELSINKI)))
-        self.assertEqual(str(datetime(1900, 1, 1, 0, 0, tzinfo=tzc)),
-                             "1900-01-01 00:00:00+01:40")
+
+
+
+
 
     def testLeapCountDecodesProperly(self):
         # This timezone has leapcnt, and failed to decode until
@@ -1974,6 +1994,27 @@ def testTZSetDoesntCorrupt(self):
             self.assertEqual(str(dt), '2014-07-20 12:34:56+00:00')
 
 
+@pytest.mark.tzfile
+@pytest.mark.skipif(not SUPPORTS_SUB_MINUTE_OFFSETS,
+                    reason='Sub-minute offsets not supported')
+def test_tzfile_sub_minute_offset():
+    # If user running python 3.6 or newer, exact offset is used
+    tzc = tz.tzfile(BytesIO(base64.b64decode(EUROPE_HELSINKI)))
+    offset = timedelta(hours=1, minutes=39, seconds=52)
+    assert datetime(1900, 1, 1, 0, 0, tzinfo=tzc).utcoffset() == offset
+
+
+@pytest.mark.tzfile
+@pytest.mark.skipif(SUPPORTS_SUB_MINUTE_OFFSETS,
+                    reason='Sub-minute offsets supported.')
+def test_sub_minute_rounding_tzfile():
+    # This timezone has an offset of 5992 seconds in 1900-01-01.
+    # For python version pre-3.6, this will be rounded
+    tzc = tz.tzfile(BytesIO(base64.b64decode(EUROPE_HELSINKI)))
+    offset = timedelta(hours=1, minutes=40)
+    assert datetime(1900, 1, 1, 0, 0, tzinfo=tzc).utcoffset() == offset
+
+
 @unittest.skipUnless(IS_WIN, "Requires Windows")
 class TzWinTest(unittest.TestCase, TzWinFoldMixin):
     def setUp(self):
@@ -2618,8 +2659,7 @@ def __get_kiritimati_resolve_imaginary_test():
     return (tzi, ) + dates
 
 
-@pytest.mark.tz_resolve_imaginary
-@pytest.mark.parametrize('tzi, dt, dt_exp', [
+resolve_imaginary_tests = [
     (tz.gettz('Europe/London'),
      datetime(2018, 3, 25, 1, 30), datetime(2018, 3, 25, 2, 30)),
     (tz.gettz('America/New_York'),
@@ -2627,24 +2667,20 @@ def __get_kiritimati_resolve_imaginary_test():
     (tz.gettz('Australia/Sydney'),
      datetime(2014, 10, 5, 2, 0), datetime(2014, 10, 5, 3, 0)),
     __get_kiritimati_resolve_imaginary_test(),
-])
-def test_resolve_imaginary(tzi, dt, dt_exp):
-    dt = dt.replace(tzinfo=tzi)
-    dt_exp = dt_exp.replace(tzinfo=tzi)
+]
 
-    dt_r = tz.resolve_imaginary(dt)
-    assert dt_r == dt_exp
-    assert dt_r.tzname() == dt_exp.tzname()
-    assert dt_r.utcoffset() == dt_exp.utcoffset()
+
+if SUPPORTS_SUB_MINUTE_OFFSETS:
+    resolve_imaginary_tests.append(
+        (tz.gettz('Africa/Monrovia'),
+         datetime(1972, 1, 7, 0, 30), datetime(1972, 1, 7, 1, 14, 30)))
 
 
-@pytest.mark.xfail
 @pytest.mark.tz_resolve_imaginary
-def test_resolve_imaginary_monrovia():
-    # See GH #582 - When that is resolved, move this into test_resolve_imaginary
-    tzi = tz.gettz('Africa/Monrovia')
-    dt = datetime(1972, 1, 7, hour=0, minute=30, second=0, tzinfo=tzi)
-    dt_exp = datetime(1972, 1, 7, hour=1, minute=14, second=30, tzinfo=tzi)
+@pytest.mark.parametrize('tzi, dt, dt_exp', resolve_imaginary_tests)
+def test_resolve_imaginary(tzi, dt, dt_exp):
+    dt = dt.replace(tzinfo=tzi)
+    dt_exp = dt_exp.replace(tzinfo=tzi)
 
     dt_r = tz.resolve_imaginary(dt)
     assert dt_r == dt_exp