|
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