From 957c36fe39fb2204e1e969c8fada509c8e38537d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 2 Apr 2016 13:41:19 -0400 Subject: [PATCH 1/3] Properly handle UTC conversion in date2num. --- lib/matplotlib/dates.py | 25 +++++----- lib/matplotlib/tests/test_dates.py | 74 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index f4d6858f125d..f02f461ee60c 100755 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -212,23 +212,24 @@ def _to_ordinalf(dt): days, preserving hours, minutes, seconds and microseconds. Return value is a :func:`float`. """ - - if hasattr(dt, 'tzinfo') and dt.tzinfo is not None: - delta = dt.tzinfo.utcoffset(dt) - if delta is not None: - dt -= delta + # Convert to UTC + tzi = getattr(dt, 'tzinfo', None) + if tzi is not None: + dt = dt.astimezone(UTC) + tzi = UTC base = float(dt.toordinal()) - if isinstance(dt, datetime.datetime): - # Get a datetime object at midnight in the same time zone as dt. - cdate = dt.date() - midnight_time = datetime.time(0, 0, 0, tzinfo=dt.tzinfo) + + # If it's sufficiently datetime-like, it will have a `date()` method + cdate = getattr(dt, 'date', lambda: None)() + if cdate is not None: + # Get a datetime object at midnight UTC + midnight_time = datetime.time(0, tzinfo=tzi) rdt = datetime.datetime.combine(cdate, midnight_time) - td_remainder = _total_seconds(dt - rdt) - if td_remainder > 0: - base += td_remainder / SEC_PER_DAY + # Append the seconds as a fraction of a day + base += _total_seconds(dt - rdt) / SEC_PER_DAY return base diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 54afc633bf7e..b0ad204bfe67 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -9,6 +9,8 @@ import tempfile import dateutil +import pytz + try: # mock in python 3.3+ from unittest import mock @@ -355,6 +357,78 @@ def test_date_inverted_limit(): fig.subplots_adjust(left=0.25) +def test_date2num_dst(): + # Test for github issue #3896, but in date2num around DST transitions + # with a timezone-aware pandas date_range object. + + class dt_tzaware(datetime.datetime): + """ + This bug specifically occurs because of the normalization behavior of + pandas Timestamp objects, so in order to replicate it, we need a + datetime-like object that applies timezone normalization after + subtraction. + """ + def __sub__(self, other): + r = super(dt_tzaware, self).__sub__(other) + tzinfo = getattr(r, 'tzinfo', None) + + if tzinfo is not None: + localizer = getattr(tzinfo, 'normalize', None) + if localizer is not None: + r = tzinfo.normalize(r) + + if isinstance(r, datetime.datetime): + r = self.mk_tzaware(r) + + return r + + def __add__(self, other): + return self.mk_tzaware(super(dt_tzaware, self).__add__(other)) + + def astimezone(self, tzinfo): + dt = super(dt_tzaware, self).astimezone(tzinfo) + return self.mk_tzaware(dt) + + @classmethod + def mk_tzaware(cls, datetime_obj): + kwargs = {} + attrs = ('year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'microsecond', + 'tzinfo') + + for attr in attrs: + val = getattr(datetime_obj, attr, None) + if val is not None: + kwargs[attr] = val + + return cls(**kwargs) + + # Timezones + BRUSSELS = pytz.timezone('Europe/Brussels') + UTC = pytz.UTC + + # Create a list of timezone-aware datetime objects in UTC + # Interval is 0b0.0000011 days, to prevent float rounding issues + dtstart = dt_tzaware(2014, 3, 30, 0, 0, tzinfo=UTC) + interval = datetime.timedelta(minutes=33, seconds=45) + interval_days = 0.0234375 # 2025 / 86400 seconds + N = 8 + + dt_utc = [dtstart + i * interval for i in range(N)] + dt_bxl = [d.astimezone(BRUSSELS) for d in dt_utc] + + expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)] + + actual_ordinalf = list(mdates.date2num(dt_bxl)) + + assert_equal(actual_ordinalf, expected_ordinalf) + + if __name__ == '__main__': import nose nose.runmodule(argv=['-s', '--with-doctest'], exit=False) From 66c0ed7a019fb734de3615302923839bc692ba12 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 2 Apr 2016 16:22:20 -0400 Subject: [PATCH 2/3] Add pandas-based test. --- lib/matplotlib/tests/test_dates.py | 56 ++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index b0ad204bfe67..de7c1efd0454 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -17,6 +17,7 @@ except ImportError: import mock from nose.tools import assert_raises, assert_equal +from nose.plugins.skip import SkipTest from matplotlib.testing.decorators import image_comparison, cleanup import matplotlib.pyplot as plt @@ -357,6 +358,27 @@ def test_date_inverted_limit(): fig.subplots_adjust(left=0.25) +def _test_date2num_dst(date_range, tz_convert): + # Timezones + BRUSSELS = pytz.timezone('Europe/Brussels') + UTC = pytz.UTC + + # Create a list of timezone-aware datetime objects in UTC + # Interval is 0b0.0000011 days, to prevent float rounding issues + dtstart = datetime.datetime(2014, 3, 30, 0, 0, tzinfo=UTC) + interval = datetime.timedelta(minutes=33, seconds=45) + interval_days = 0.0234375 # 2025 / 86400 seconds + N = 8 + + dt_utc = date_range(start=dtstart, freq=interval, periods=N) + dt_bxl = tz_convert(dt_utc, BRUSSELS) + + expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)] + actual_ordinalf = list(mdates.date2num(dt_bxl)) + + assert_equal(actual_ordinalf, expected_ordinalf) + + def test_date2num_dst(): # Test for github issue #3896, but in date2num around DST transitions # with a timezone-aware pandas date_range object. @@ -408,25 +430,31 @@ def mk_tzaware(cls, datetime_obj): return cls(**kwargs) - # Timezones - BRUSSELS = pytz.timezone('Europe/Brussels') - UTC = pytz.UTC + # Define a date_range function similar to pandas.date_range + def date_range(start, freq, periods): + dtstart = dt_tzaware.mk_tzaware(start) - # Create a list of timezone-aware datetime objects in UTC - # Interval is 0b0.0000011 days, to prevent float rounding issues - dtstart = dt_tzaware(2014, 3, 30, 0, 0, tzinfo=UTC) - interval = datetime.timedelta(minutes=33, seconds=45) - interval_days = 0.0234375 # 2025 / 86400 seconds - N = 8 + return [dtstart + (i * freq) for i in range(periods)] - dt_utc = [dtstart + i * interval for i in range(N)] - dt_bxl = [d.astimezone(BRUSSELS) for d in dt_utc] + # Define a tz_convert function that converts a list to a new time zone. + def tz_convert(dt_list, tzinfo): + return [d.astimezone(tzinfo) for d in dt_list] - expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)] + _test_date2num_dst(date_range, tz_convert) - actual_ordinalf = list(mdates.date2num(dt_bxl)) - assert_equal(actual_ordinalf, expected_ordinalf) +def test_date2num_dst_pandas(): + # Test for github issue #3896, but in date2num around DST transitions + # with a timezone-aware pandas date_range object. + try: + import pandas as pd + except ImportError: + raise SkipTest('pandas not installed') + + def tz_convert(*args): + return pd.DatetimeIndex.tz_convert(*args).astype(datetime.datetime) + + _test_date2num_dst(pd.date_range, tz_convert) if __name__ == '__main__': From 3e6e8d1324e4aac4c0cf08dcffab451e1ee5f525 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Sat, 2 Apr 2016 18:37:40 -0400 Subject: [PATCH 3/3] Remove Python 2.6 compatibility function --- lib/matplotlib/dates.py | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/lib/matplotlib/dates.py b/lib/matplotlib/dates.py index f02f461ee60c..4f899dd07902 100755 --- a/lib/matplotlib/dates.py +++ b/lib/matplotlib/dates.py @@ -229,7 +229,7 @@ def _to_ordinalf(dt): rdt = datetime.datetime.combine(cdate, midnight_time) # Append the seconds as a fraction of a day - base += _total_seconds(dt - rdt) / SEC_PER_DAY + base += (dt - rdt).total_seconds() / SEC_PER_DAY return base @@ -237,23 +237,6 @@ def _to_ordinalf(dt): # a version of _to_ordinalf that can operate on numpy arrays _to_ordinalf_np_vectorized = np.vectorize(_to_ordinalf) -try: - # Available as a native method in Python >= 2.7. - _total_seconds = datetime.timedelta.total_seconds -except AttributeError: - def _total_seconds(tdelta): - """ - Alias providing support for datetime.timedelta.total_seconds() function - calls even in Python < 2.7. - - The input `tdelta` is a datetime.timedelta object, and returns a float - containing the total number of seconds representing the `tdelta` - duration. For large durations (> 270 on most platforms), this loses - microsecond accuracy. - """ - return (tdelta.microseconds + - (tdelta.seconds + tdelta.days * SEC_PER_DAY) * 1e6) * 1e-6 - def _from_ordinalf(x, tz=None): """ @@ -433,7 +416,7 @@ def drange(dstart, dend, delta): """ f1 = _to_ordinalf(dstart) f2 = _to_ordinalf(dend) - step = _total_seconds(delta) / SEC_PER_DAY + step = delta.total_seconds() / SEC_PER_DAY # calculate the difference between dend and dstart in times of delta num = int(np.ceil((f2 - f1) / step)) @@ -1062,8 +1045,8 @@ def get_locator(self, dmin, dmax): numDays = tdelta.days # Avoids estimates of days/month, days/year numHours = (numDays * HOURS_PER_DAY) + delta.hours numMinutes = (numHours * MIN_PER_HOUR) + delta.minutes - numSeconds = np.floor(_total_seconds(tdelta)) - numMicroseconds = np.floor(_total_seconds(tdelta) * 1e6) + numSeconds = np.floor(tdelta.total_seconds()) + numMicroseconds = np.floor(tdelta.total_seconds() * 1e6) nums = [numYears, numMonths, numDays, numHours, numMinutes, numSeconds, numMicroseconds] @@ -1403,7 +1386,7 @@ def _close_to_dt(d1, d2, epsilon=5): Assert that datetimes *d1* and *d2* are within *epsilon* microseconds. """ delta = d2 - d1 - mus = abs(_total_seconds(delta) * 1e6) + mus = abs(delta.total_seconds() * 1e6) assert mus < epsilon