Repository: dateutil/dateutil (Python)

Issue:
utcoffset/tzname incorrect immediately before Samoan offset change
First reported by @MartinThoma in [this SO question](https://stackoverflow.com/questions/52136980), it seems that there is a bug in the way `tzfile` handles the local transition times during a base offset change:

```python
from datetime import datetime, timedelta
from dateutil import tz

APIA = tz.gettz('Pacific/Apia')
dt0 = datetime.fromisoformat('2011-12-29T20:00-10:00')

for i in range(5):
    dt = (dt0 + timedelta(hours=i))
    dt = dt.astimezone(APIA)
    print(f'{dt.isoformat()}')

## Result:
# 2011-12-29T20:00:00-10:00
# 2011-12-29T21:00:00-10:00
# 2011-12-29T22:00:00-10:00
# 2011-12-29T23:00:00+14:00
# 2011-12-31T00:00:00+14:00
```

Note that while `fromutc` sets the local datetime correctly, `utcoffset` returns `+14:00` an hour early! This is because the assumption in the code is that these base offset shifts don't happen during DST, which leads to the "local" (but not the UTC) transition time being off by 1 DST offset.

PR #810 should fix this.


Hints:

Patch:
diff --git a/dateutil/tz/tz.py b/dateutil/tz/tz.py
--- a/dateutil/tz/tz.py
+++ b/dateutil/tz/tz.py
@@ -657,37 +657,44 @@ def _read_tzfile(self, fileobj):
         # isgmt are off, so it should be in wall time. OTOH, it's
         # always in gmt time. Let me know if you have comments
         # about this.
-        laststdoffset = None
+        lastdst = None
+        lastoffset = None
+        lastdstoffset = None
+        lastbaseoffset = None
         out.trans_list = []
-        for i, tti in enumerate(out.trans_idx):
-            if not tti.isdst:
-                offset = tti.offset
-                laststdoffset = offset
-            else:
-                if laststdoffset is not None:
-                    # Store the DST offset as well and update it in the list
-                    tti.dstoffset = tti.offset - laststdoffset
-                    out.trans_idx[i] = tti
-
-                offset = laststdoffset or 0
-
-            out.trans_list.append(out.trans_list_utc[i] + offset)
-
-        # In case we missed any DST offsets on the way in for some reason, make
-        # a second pass over the list, looking for the /next/ DST offset.
-        laststdoffset = None
-        for i in reversed(range(len(out.trans_idx))):
-            tti = out.trans_idx[i]
-            if tti.isdst:
-                if not (tti.dstoffset or laststdoffset is None):
-                    tti.dstoffset = tti.offset - laststdoffset
-            else:
-                laststdoffset = tti.offset
 
-            if not isinstance(tti.dstoffset, datetime.timedelta):
-                tti.dstoffset = datetime.timedelta(seconds=tti.dstoffset)
-
-            out.trans_idx[i] = tti
+        for i, tti in enumerate(out.trans_idx):
+            offset = tti.offset
+            dstoffset = 0
+
+            if lastdst is not None:
+                if tti.isdst:
+                    if not lastdst:
+                        dstoffset = offset - lastoffset
+
+                    if not dstoffset and lastdstoffset:
+                        dstoffset = lastdstoffset
+
+                    tti.dstoffset = datetime.timedelta(seconds=dstoffset)
+                    lastdstoffset = dstoffset
+
+            # If a time zone changes its base offset during a DST transition,
+            # then you need to adjust by the previous base offset to get the
+            # transition time in local time. Otherwise you use the current
+            # base offset. Ideally, I would have some mathematical proof of
+            # why this is true, but I haven't really thought about it enough.
+            baseoffset = offset - dstoffset
+            adjustment = baseoffset
+            if (lastbaseoffset is not None and baseoffset != lastbaseoffset
+                    and tti.isdst != lastdst):
+                # The base DST has changed
+                adjustment = lastbaseoffset
+
+            lastdst = tti.isdst
+            lastoffset = offset
+            lastbaseoffset = baseoffset
+
+            out.trans_list.append(out.trans_list_utc[i] + adjustment)
 
         out.trans_idx = tuple(out.trans_idx)
         out.trans_list = tuple(out.trans_list)


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
@@ -1950,11 +1950,6 @@ def testFilestreamWithNameRepr(self):
         tzc = tz.tzfile(fileobj)
         self.assertEqual(repr(tzc), 'tzfile(' + repr('foo') + ')')
 
-
-
-
-
-
     def testLeapCountDecodesProperly(self):
         # This timezone has leapcnt, and failed to decode until
         # Eugene Oden notified about the issue.
@@ -2036,6 +2031,19 @@ def test_sub_minute_rounding_tzfile():
     assert datetime(1900, 1, 1, 0, 0, tzinfo=tzc).utcoffset() == offset
 
 
+@pytest.mark.tzfile
+def test_samoa_transition():
+    # utcoffset() was erroneously returning +14:00 an hour early (GH #812)
+    APIA = tz.gettz('Pacific/Apia')
+    dt = datetime(2011, 12, 29, 23, 59, tzinfo=APIA)
+    assert dt.utcoffset() == timedelta(hours=-10)
+
+    # Make sure the transition actually works, too
+    dt_after = (dt.astimezone(tz.UTC) + timedelta(minutes=1)).astimezone(APIA)
+    assert dt_after == datetime(2011, 12, 31, tzinfo=APIA)
+    assert dt_after.utcoffset() == timedelta(hours=14)
+
+
 @unittest.skipUnless(IS_WIN, "Requires Windows")
 class TzWinTest(unittest.TestCase, TzWinFoldMixin):
     def setUp(self):