| 
9 | 9 | import tempfile  | 
10 | 10 | 
 
  | 
11 | 11 | import dateutil  | 
 | 12 | +import pytz  | 
 | 13 | + | 
12 | 14 | try:  | 
13 | 15 |     # mock in python 3.3+  | 
14 | 16 |     from unittest import mock  | 
15 | 17 | except ImportError:  | 
16 | 18 |     import mock  | 
17 | 19 | from nose.tools import assert_raises, assert_equal  | 
 | 20 | +from nose.plugins.skip import SkipTest  | 
18 | 21 | 
 
  | 
19 | 22 | from matplotlib.testing.decorators import image_comparison, cleanup  | 
20 | 23 | import matplotlib.pyplot as plt  | 
@@ -355,6 +358,105 @@ def test_date_inverted_limit():  | 
355 | 358 |     fig.subplots_adjust(left=0.25)  | 
356 | 359 | 
 
  | 
357 | 360 | 
 
  | 
 | 361 | +def _test_date2num_dst(date_range, tz_convert):  | 
 | 362 | +    # Timezones  | 
 | 363 | +    BRUSSELS = pytz.timezone('Europe/Brussels')  | 
 | 364 | +    UTC = pytz.UTC  | 
 | 365 | + | 
 | 366 | +    # Create a list of timezone-aware datetime objects in UTC  | 
 | 367 | +    # Interval is 0b0.0000011 days, to prevent float rounding issues  | 
 | 368 | +    dtstart = datetime.datetime(2014, 3, 30, 0, 0, tzinfo=UTC)  | 
 | 369 | +    interval = datetime.timedelta(minutes=33, seconds=45)  | 
 | 370 | +    interval_days = 0.0234375   # 2025 / 86400 seconds  | 
 | 371 | +    N = 8  | 
 | 372 | + | 
 | 373 | +    dt_utc = date_range(start=dtstart, freq=interval, periods=N)  | 
 | 374 | +    dt_bxl = tz_convert(dt_utc, BRUSSELS)  | 
 | 375 | + | 
 | 376 | +    expected_ordinalf = [735322.0 + (i * interval_days) for i in range(N)]  | 
 | 377 | +    actual_ordinalf = list(mdates.date2num(dt_bxl))  | 
 | 378 | + | 
 | 379 | +    assert_equal(actual_ordinalf, expected_ordinalf)  | 
 | 380 | + | 
 | 381 | + | 
 | 382 | +def test_date2num_dst():  | 
 | 383 | +    # Test for github issue #3896, but in date2num around DST transitions  | 
 | 384 | +    # with a timezone-aware pandas date_range object.  | 
 | 385 | + | 
 | 386 | +    class dt_tzaware(datetime.datetime):  | 
 | 387 | +        """  | 
 | 388 | +        This bug specifically occurs because of the normalization behavior of  | 
 | 389 | +        pandas Timestamp objects, so in order to replicate it, we need a  | 
 | 390 | +        datetime-like object that applies timezone normalization after  | 
 | 391 | +        subtraction.  | 
 | 392 | +        """  | 
 | 393 | +        def __sub__(self, other):  | 
 | 394 | +            r = super(dt_tzaware, self).__sub__(other)  | 
 | 395 | +            tzinfo = getattr(r, 'tzinfo', None)  | 
 | 396 | + | 
 | 397 | +            if tzinfo is not None:  | 
 | 398 | +                localizer = getattr(tzinfo, 'normalize', None)  | 
 | 399 | +                if localizer is not None:  | 
 | 400 | +                    r = tzinfo.normalize(r)  | 
 | 401 | + | 
 | 402 | +            if isinstance(r, datetime.datetime):  | 
 | 403 | +                r = self.mk_tzaware(r)  | 
 | 404 | + | 
 | 405 | +            return r  | 
 | 406 | + | 
 | 407 | +        def __add__(self, other):  | 
 | 408 | +            return self.mk_tzaware(super(dt_tzaware, self).__add__(other))  | 
 | 409 | + | 
 | 410 | +        def astimezone(self, tzinfo):  | 
 | 411 | +            dt = super(dt_tzaware, self).astimezone(tzinfo)  | 
 | 412 | +            return self.mk_tzaware(dt)  | 
 | 413 | + | 
 | 414 | +        @classmethod  | 
 | 415 | +        def mk_tzaware(cls, datetime_obj):  | 
 | 416 | +            kwargs = {}  | 
 | 417 | +            attrs = ('year',  | 
 | 418 | +                     'month',  | 
 | 419 | +                     'day',  | 
 | 420 | +                     'hour',  | 
 | 421 | +                     'minute',  | 
 | 422 | +                     'second',  | 
 | 423 | +                     'microsecond',  | 
 | 424 | +                     'tzinfo')  | 
 | 425 | + | 
 | 426 | +            for attr in attrs:  | 
 | 427 | +                val = getattr(datetime_obj, attr, None)  | 
 | 428 | +                if val is not None:  | 
 | 429 | +                    kwargs[attr] = val  | 
 | 430 | + | 
 | 431 | +            return cls(**kwargs)  | 
 | 432 | + | 
 | 433 | +    # Define a date_range function similar to pandas.date_range  | 
 | 434 | +    def date_range(start, freq, periods):  | 
 | 435 | +        dtstart = dt_tzaware.mk_tzaware(start)  | 
 | 436 | + | 
 | 437 | +        return [dtstart + (i * freq) for i in range(periods)]  | 
 | 438 | + | 
 | 439 | +    # Define a tz_convert function that converts a list to a new time zone.  | 
 | 440 | +    def tz_convert(dt_list, tzinfo):  | 
 | 441 | +        return [d.astimezone(tzinfo) for d in dt_list]  | 
 | 442 | + | 
 | 443 | +    _test_date2num_dst(date_range, tz_convert)  | 
 | 444 | + | 
 | 445 | + | 
 | 446 | +def test_date2num_dst_pandas():  | 
 | 447 | +    # Test for github issue #3896, but in date2num around DST transitions  | 
 | 448 | +    # with a timezone-aware pandas date_range object.  | 
 | 449 | +    try:  | 
 | 450 | +        import pandas as pd  | 
 | 451 | +    except ImportError:  | 
 | 452 | +        raise SkipTest('pandas not installed')  | 
 | 453 | + | 
 | 454 | +    def tz_convert(*args):  | 
 | 455 | +        return pd.DatetimeIndex.tz_convert(*args).astype(datetime.datetime)  | 
 | 456 | + | 
 | 457 | +    _test_date2num_dst(pd.date_range, tz_convert)  | 
 | 458 | + | 
 | 459 | + | 
358 | 460 | if __name__ == '__main__':  | 
359 | 461 |     import nose  | 
360 | 462 |     nose.runmodule(argv=['-s', '--with-doctest'], exit=False)  | 
0 commit comments