Skip to content

Commit ad40979

Browse files
committed
Merge pull request #7465 from sinhrks/inittz
BUG: Some offsets.apply cannot handle tz properly
2 parents 69562c5 + 69400ea commit ad40979

File tree

5 files changed

+80
-18
lines changed

5 files changed

+80
-18
lines changed

doc/source/v0.14.1.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ Bug Fixes
226226

227227

228228

229+
- Bug in passing input with ``tzinfo`` to some offsets ``apply``, ``rollforward`` or ``rollback`` resets ``tzinfo`` or raises ``ValueError`` (:issue:`7465`)
230+
229231

230232
- BUG in ``resample`` raises ``ValueError`` when target contains ``NaT`` (:issue:`7227`)
231233

pandas/tseries/offsets.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,20 @@ def wrapper(self, other):
4848
elif isinstance(other, np.datetime64):
4949
other = as_timestamp(other)
5050

51+
tz = getattr(other, 'tzinfo', None)
5152
result = func(self, other)
5253

5354
if self.normalize:
5455
result = tslib.normalize_date(result)
5556

5657
if isinstance(other, Timestamp) and not isinstance(result, Timestamp):
5758
result = as_timestamp(result)
59+
60+
if tz is not None:
61+
if isinstance(result, Timestamp) and result.tzinfo is None:
62+
result = result.tz_localize(tz)
63+
elif isinstance(result, datetime) and result.tzinfo is None:
64+
result = tz.localize(result)
5865
return result
5966
return wrapper
6067

@@ -570,6 +577,11 @@ def _to_dt64(dt, dtype='datetime64'):
570577
# > np.datetime64(dt.datetime(2013,5,1),dtype='datetime64[D]')
571578
# numpy.datetime64('2013-05-01T02:00:00.000000+0200')
572579
# Thus astype is needed to cast datetime to datetime64[D]
580+
581+
if getattr(dt, 'tzinfo', None) is not None:
582+
i8 = tslib.pydt_to_i8(dt)
583+
dt = tslib.tz_convert_single(i8, 'UTC', dt.tzinfo)
584+
dt = Timestamp(dt)
573585
dt = np.datetime64(dt)
574586
if dt.dtype.name != dtype:
575587
dt = dt.astype(dtype)
@@ -966,13 +978,18 @@ def apply(self, other):
966978
months = self.n + 1
967979

968980
other = self.getOffsetOfMonth(as_datetime(other) + relativedelta(months=months, day=1))
969-
other = datetime(other.year, other.month, other.day,
970-
base.hour, base.minute, base.second, base.microsecond)
981+
other = datetime(other.year, other.month, other.day, base.hour,
982+
base.minute, base.second, base.microsecond)
983+
if getattr(other, 'tzinfo', None) is not None:
984+
other = other.tzinfo.localize(other)
971985
return other
972986

973987
def getOffsetOfMonth(self, dt):
974988
w = Week(weekday=self.weekday)
989+
975990
d = datetime(dt.year, dt.month, 1)
991+
if getattr(dt, 'tzinfo', None) is not None:
992+
d = dt.tzinfo.localize(d)
976993

977994
d = w.rollforward(d)
978995

@@ -985,6 +1002,8 @@ def onOffset(self, dt):
9851002
if self.normalize and not _is_normalized(dt):
9861003
return False
9871004
d = datetime(dt.year, dt.month, dt.day)
1005+
if getattr(dt, 'tzinfo', None) is not None:
1006+
d = dt.tzinfo.localize(d)
9881007
return d == self.getOffsetOfMonth(dt)
9891008

9901009
@property
@@ -1056,6 +1075,8 @@ def apply(self, other):
10561075
def getOffsetOfMonth(self, dt):
10571076
m = MonthEnd()
10581077
d = datetime(dt.year, dt.month, 1, dt.hour, dt.minute, dt.second, dt.microsecond)
1078+
if getattr(dt, 'tzinfo', None) is not None:
1079+
d = dt.tzinfo.localize(d)
10591080

10601081
eom = m.rollforward(d)
10611082

@@ -1134,6 +1155,10 @@ class BQuarterEnd(QuarterOffset):
11341155
@apply_wraps
11351156
def apply(self, other):
11361157
n = self.n
1158+
base = other
1159+
other = datetime(other.year, other.month, other.day,
1160+
other.hour, other.minute, other.second,
1161+
other.microsecond)
11371162

11381163
wkday, days_in_month = tslib.monthrange(other.year, other.month)
11391164
lastBDay = days_in_month - max(((wkday + days_in_month - 1)
@@ -1149,7 +1174,8 @@ def apply(self, other):
11491174
n = n + 1
11501175

11511176
other = as_datetime(other) + relativedelta(months=monthsToGo + 3 * n, day=31)
1152-
1177+
if getattr(base, 'tzinfo', None) is not None:
1178+
other = base.tzinfo.localize(other)
11531179
if other.weekday() > 4:
11541180
other = other - BDay()
11551181

@@ -1216,6 +1242,8 @@ def apply(self, other):
12161242
result = datetime(other.year, other.month, first,
12171243
other.hour, other.minute, other.second,
12181244
other.microsecond)
1245+
if getattr(other, 'tzinfo', None) is not None:
1246+
result = other.tzinfo.localize(result)
12191247
return as_timestamp(result)
12201248

12211249

@@ -1242,6 +1270,10 @@ def isAnchored(self):
12421270
@apply_wraps
12431271
def apply(self, other):
12441272
n = self.n
1273+
base = other
1274+
other = datetime(other.year, other.month, other.day,
1275+
other.hour, other.minute, other.second,
1276+
other.microsecond)
12451277
other = as_datetime(other)
12461278

12471279
wkday, days_in_month = tslib.monthrange(other.year, other.month)
@@ -1254,7 +1286,8 @@ def apply(self, other):
12541286
n = n - 1
12551287

12561288
other = other + relativedelta(months=monthsToGo + 3 * n, day=31)
1257-
1289+
if getattr(base, 'tzinfo', None) is not None:
1290+
other = base.tzinfo.localize(other)
12581291
return as_timestamp(other)
12591292

12601293
def onOffset(self, dt):
@@ -1589,6 +1622,10 @@ def apply(self, other):
15891622
datetime(other.year, self.startingMonth, 1))
15901623
next_year = self.get_year_end(
15911624
datetime(other.year + 1, self.startingMonth, 1))
1625+
if getattr(other, 'tzinfo', None) is not None:
1626+
prev_year = other.tzinfo.localize(prev_year)
1627+
cur_year = other.tzinfo.localize(cur_year)
1628+
next_year = other.tzinfo.localize(next_year)
15921629

15931630
if n > 0:
15941631
if other == prev_year:
@@ -1647,7 +1684,9 @@ def get_year_end(self, dt):
16471684
return self._get_year_end_last(dt)
16481685

16491686
def get_target_month_end(self, dt):
1650-
target_month = datetime(year=dt.year, month=self.startingMonth, day=1)
1687+
target_month = datetime(dt.year, self.startingMonth, 1)
1688+
if getattr(dt, 'tzinfo', None) is not None:
1689+
target_month = dt.tzinfo.localize(target_month)
16511690
next_month_first_of = target_month + relativedelta(months=+1)
16521691
return next_month_first_of + relativedelta(days=-1)
16531692

@@ -1665,7 +1704,9 @@ def _get_year_end_nearest(self, dt):
16651704
return backward
16661705

16671706
def _get_year_end_last(self, dt):
1668-
current_year = datetime(year=dt.year, month=self.startingMonth, day=1)
1707+
current_year = datetime(dt.year, self.startingMonth, 1)
1708+
if getattr(dt, 'tzinfo', None) is not None:
1709+
current_year = dt.tzinfo.localize(current_year)
16691710
return current_year + self._offset_lwom
16701711

16711712
@property
@@ -1878,13 +1919,14 @@ class Easter(DateOffset):
18781919
'''
18791920
def __init__(self, n=1, **kwds):
18801921
super(Easter, self).__init__(n, **kwds)
1881-
1922+
18821923
@apply_wraps
18831924
def apply(self, other):
1884-
18851925
currentEaster = easter(other.year)
18861926
currentEaster = datetime(currentEaster.year, currentEaster.month, currentEaster.day)
1887-
1927+
if getattr(other, 'tzinfo', None) is not None:
1928+
currentEaster = other.tzinfo.localize(currentEaster)
1929+
18881930
# NOTE: easter returns a datetime.date so we have to convert to type of other
18891931
if self.n >= 0:
18901932
if other >= currentEaster:
@@ -1905,6 +1947,7 @@ def onOffset(self, dt):
19051947
if self.normalize and not _is_normalized(dt):
19061948
return False
19071949
return date(dt.year, dt.month, dt.day) == easter(dt.year)
1950+
19081951
#----------------------------------------------------------------------
19091952
# Ticks
19101953

pandas/tseries/tests/test_offsets.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ def setUp(self):
185185
'Milli': Timestamp('2011-01-01 09:00:00.001000'),
186186
'Micro': Timestamp('2011-01-01 09:00:00.000001'),
187187
'Nano': Timestamp(np.datetime64('2011-01-01T09:00:00.000000001Z'))}
188+
189+
self.timezones = ['UTC', 'Asia/Tokyo', 'US/Eastern']
188190

189191
def test_return_type(self):
190192
for offset in self.offset_types:
@@ -214,6 +216,24 @@ def _check_offsetfunc_works(self, offset, funcname, dt, expected,
214216
self.assert_(isinstance(result, Timestamp))
215217
self.assertEqual(result, expected)
216218

219+
if isinstance(dt, np.datetime64):
220+
# test tz when input is datetime or Timestamp
221+
return
222+
223+
tm._skip_if_no_pytz()
224+
import pytz
225+
for tz in self.timezones:
226+
expected_localize = expected.tz_localize(tz)
227+
228+
dt_tz = pytz.timezone(tz).localize(dt)
229+
result = func(dt_tz)
230+
self.assert_(isinstance(result, datetime))
231+
self.assertEqual(result, expected_localize)
232+
233+
result = func(Timestamp(dt, tz=tz))
234+
self.assert_(isinstance(result, datetime))
235+
self.assertEqual(result, expected_localize)
236+
217237
def _check_nanofunc_works(self, offset, funcname, dt, expected):
218238
offset = self._get_offset(offset)
219239
func = getattr(offset, funcname)
@@ -334,9 +354,7 @@ def test_rollback(self):
334354
dt, expected, normalize=True)
335355

336356
def test_onOffset(self):
337-
338357
for offset in self.offset_types:
339-
340358
dt = self.expecteds[offset.__name__]
341359
offset_s = self._get_offset(offset)
342360
self.assert_(offset_s.onOffset(dt))

pandas/tseries/tests/test_timeseries.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,6 @@ def test_recreate_from_data(self):
309309
idx = DatetimeIndex(org, freq=f)
310310
self.assertTrue(idx.equals(org))
311311

312-
# unbale to create tz-aware 'A' and 'C' freq
313-
if _np_version_under1p7:
314-
freqs = ['M', 'Q', 'D', 'B', 'T', 'S', 'L', 'U', 'H']
315-
else:
316-
freqs = ['M', 'Q', 'D', 'B', 'T', 'S', 'L', 'U', 'H', 'N']
317-
318-
for f in freqs:
319312
org = DatetimeIndex(start='2001/02/01 09:00', freq=f, tz='US/Pacific', periods=1)
320313
idx = DatetimeIndex(org, freq=f, tz='US/Pacific')
321314
self.assertTrue(idx.equals(org))

pandas/util/testing.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ def setUpClass(cls):
209209
cls.setUpClass = setUpClass
210210
return cls
211211

212+
def _skip_if_no_pytz():
213+
try:
214+
import pytz
215+
except ImportError:
216+
import nose
217+
raise nose.SkipTest("pytz not installed")
212218

213219
#------------------------------------------------------------------------------
214220
# locale utilities

0 commit comments

Comments
 (0)