Skip to content

Commit 175cc4f

Browse files
jbrockmendeljreback
authored andcommitted
Fix bugs in WeekOfMonth.apply, Week.onOffset (#18875)
1 parent b9cc821 commit 175cc4f

File tree

3 files changed

+118
-52
lines changed

3 files changed

+118
-52
lines changed

doc/source/whatsnew/v0.23.0.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,8 @@ Conversion
282282
- Bug in :meth:`Index.astype` with a categorical dtype where the resultant index is not converted to a :class:`CategoricalIndex` for all types of index (:issue:`18630`)
283283
- Bug in :meth:`Series.astype` and ``Categorical.astype()`` where an existing categorical data does not get updated (:issue:`10696`, :issue:`18593`)
284284
- Bug in :class:`Series` constructor with an int or float list where specifying ``dtype=str``, ``dtype='str'`` or ``dtype='U'`` failed to convert the data elements to strings (:issue:`16605`)
285+
- Bug in :class:`Timestamp` where comparison with an array of ``Timestamp`` objects would result in a ``RecursionError`` (:issue:`15183`)
286+
- Bug in :class:`WeekOfMonth` and class:`Week` where addition and subtraction did not roll correctly (:issue:`18510`,:issue:`18672`,:issue:`18864`)
285287

286288

287289
Indexing
@@ -361,4 +363,3 @@ Other
361363
^^^^^
362364

363365
- Improved error message when attempting to use a Python keyword as an identifier in a ``numexpr`` backed query (:issue:`18221`)
364-
- Bug in :class:`Timestamp` where comparison with an array of ``Timestamp`` objects would result in a ``RecursionError`` (:issue:`15183`)

pandas/tests/tseries/offsets/test_offsets.py

+34
Original file line numberDiff line numberDiff line change
@@ -3147,3 +3147,37 @@ def test_require_integers(offset_types):
31473147
cls = offset_types
31483148
with pytest.raises(ValueError):
31493149
cls(n=1.5)
3150+
3151+
3152+
def test_weeks_onoffset():
3153+
# GH#18510 Week with weekday = None, normalize = False should always
3154+
# be onOffset
3155+
offset = Week(n=2, weekday=None)
3156+
ts = Timestamp('1862-01-13 09:03:34.873477378+0210', tz='Africa/Lusaka')
3157+
fast = offset.onOffset(ts)
3158+
slow = (ts + offset) - offset == ts
3159+
assert fast == slow
3160+
3161+
# negative n
3162+
offset = Week(n=2, weekday=None)
3163+
ts = Timestamp('1856-10-24 16:18:36.556360110-0717', tz='Pacific/Easter')
3164+
fast = offset.onOffset(ts)
3165+
slow = (ts + offset) - offset == ts
3166+
assert fast == slow
3167+
3168+
3169+
def test_weekofmonth_onoffset():
3170+
# GH#18864
3171+
# Make sure that nanoseconds don't trip up onOffset (and with it apply)
3172+
offset = WeekOfMonth(n=2, week=2, weekday=0)
3173+
ts = Timestamp('1916-05-15 01:14:49.583410462+0422', tz='Asia/Qyzylorda')
3174+
fast = offset.onOffset(ts)
3175+
slow = (ts + offset) - offset == ts
3176+
assert fast == slow
3177+
3178+
# negative n
3179+
offset = WeekOfMonth(n=-3, week=1, weekday=0)
3180+
ts = Timestamp('1980-12-08 03:38:52.878321185+0500', tz='Asia/Oral')
3181+
fast = offset.onOffset(ts)
3182+
slow = (ts + offset) - offset == ts
3183+
assert fast == slow

pandas/tseries/offsets.py

+82-51
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,31 @@ def wrapper(self, other):
112112
return wrapper
113113

114114

115+
def shift_day(other, days):
116+
"""
117+
Increment the datetime `other` by the given number of days, retaining
118+
the time-portion of the datetime. For tz-naive datetimes this is
119+
equivalent to adding a timedelta. For tz-aware datetimes it is similar to
120+
dateutil's relativedelta.__add__, but handles pytz tzinfo objects.
121+
122+
Parameters
123+
----------
124+
other : datetime or Timestamp
125+
days : int
126+
127+
Returns
128+
-------
129+
shifted: datetime or Timestamp
130+
"""
131+
if other.tzinfo is None:
132+
return other + timedelta(days=days)
133+
134+
tz = other.tzinfo
135+
naive = other.replace(tzinfo=None)
136+
shifted = naive + timedelta(days=days)
137+
return tslib._localize_pydatetime(shifted, tz)
138+
139+
115140
# ---------------------------------------------------------------------
116141
# DateOffset
117142

@@ -1342,6 +1367,8 @@ def apply_index(self, i):
13421367
def onOffset(self, dt):
13431368
if self.normalize and not _is_normalized(dt):
13441369
return False
1370+
elif self.weekday is None:
1371+
return True
13451372
return dt.weekday() == self.weekday
13461373

13471374
@property
@@ -1361,7 +1388,29 @@ def _from_name(cls, suffix=None):
13611388
return cls(weekday=weekday)
13621389

13631390

1364-
class WeekOfMonth(DateOffset):
1391+
class _WeekOfMonthMixin(object):
1392+
"""Mixin for methods common to WeekOfMonth and LastWeekOfMonth"""
1393+
@apply_wraps
1394+
def apply(self, other):
1395+
compare_day = self._get_offset_day(other)
1396+
1397+
months = self.n
1398+
if months > 0 and compare_day > other.day:
1399+
months -= 1
1400+
elif months <= 0 and compare_day < other.day:
1401+
months += 1
1402+
1403+
shifted = shift_month(other, months, 'start')
1404+
to_day = self._get_offset_day(shifted)
1405+
return shift_day(shifted, to_day - shifted.day)
1406+
1407+
def onOffset(self, dt):
1408+
if self.normalize and not _is_normalized(dt):
1409+
return False
1410+
return dt.day == self._get_offset_day(dt)
1411+
1412+
1413+
class WeekOfMonth(_WeekOfMonthMixin, DateOffset):
13651414
"""
13661415
Describes monthly dates like "the Tuesday of the 2nd week of each month"
13671416
@@ -1400,34 +1449,23 @@ def __init__(self, n=1, normalize=False, week=None, weekday=None):
14001449

14011450
self.kwds = {'weekday': weekday, 'week': week}
14021451

1403-
@apply_wraps
1404-
def apply(self, other):
1405-
base = other
1406-
offsetOfMonth = self.getOffsetOfMonth(other)
1407-
1408-
months = self.n
1409-
if months > 0 and offsetOfMonth > other:
1410-
months -= 1
1411-
elif months <= 0 and offsetOfMonth < other:
1412-
months += 1
1413-
1414-
other = self.getOffsetOfMonth(shift_month(other, months, 'start'))
1415-
other = datetime(other.year, other.month, other.day, base.hour,
1416-
base.minute, base.second, base.microsecond)
1417-
return other
1452+
def _get_offset_day(self, other):
1453+
"""
1454+
Find the day in the same month as other that has the same
1455+
weekday as self.weekday and is the self.week'th such day in the month.
14181456
1419-
def getOffsetOfMonth(self, dt):
1420-
w = Week(weekday=self.weekday)
1421-
d = datetime(dt.year, dt.month, 1, tzinfo=dt.tzinfo)
1422-
# TODO: Is this DST-safe?
1423-
d = w.rollforward(d)
1424-
return d + timedelta(weeks=self.week)
1457+
Parameters
1458+
----------
1459+
other: datetime
14251460
1426-
def onOffset(self, dt):
1427-
if self.normalize and not _is_normalized(dt):
1428-
return False
1429-
d = datetime(dt.year, dt.month, dt.day, tzinfo=dt.tzinfo)
1430-
return d == self.getOffsetOfMonth(dt)
1461+
Returns
1462+
-------
1463+
day: int
1464+
"""
1465+
mstart = datetime(other.year, other.month, 1)
1466+
wday = mstart.weekday()
1467+
shift_days = (self.weekday - wday) % 7
1468+
return 1 + shift_days + self.week * 7
14311469

14321470
@property
14331471
def rule_code(self):
@@ -1448,7 +1486,7 @@ def _from_name(cls, suffix=None):
14481486
return cls(week=week, weekday=weekday)
14491487

14501488

1451-
class LastWeekOfMonth(DateOffset):
1489+
class LastWeekOfMonth(_WeekOfMonthMixin, DateOffset):
14521490
"""
14531491
Describes monthly dates in last week of month like "the last Tuesday of
14541492
each month"
@@ -1482,31 +1520,24 @@ def __init__(self, n=1, normalize=False, weekday=None):
14821520

14831521
self.kwds = {'weekday': weekday}
14841522

1485-
@apply_wraps
1486-
def apply(self, other):
1487-
offsetOfMonth = self.getOffsetOfMonth(other)
1488-
1489-
months = self.n
1490-
if months > 0 and offsetOfMonth > other:
1491-
months -= 1
1492-
elif months <= 0 and offsetOfMonth < other:
1493-
months += 1
1494-
1495-
return self.getOffsetOfMonth(shift_month(other, months, 'start'))
1523+
def _get_offset_day(self, other):
1524+
"""
1525+
Find the day in the same month as other that has the same
1526+
weekday as self.weekday and is the last such day in the month.
14961527
1497-
def getOffsetOfMonth(self, dt):
1498-
m = MonthEnd()
1499-
d = datetime(dt.year, dt.month, 1, dt.hour, dt.minute,
1500-
dt.second, dt.microsecond, tzinfo=dt.tzinfo)
1501-
eom = m.rollforward(d)
1502-
# TODO: Is this DST-safe?
1503-
w = Week(weekday=self.weekday)
1504-
return w.rollback(eom)
1528+
Parameters
1529+
----------
1530+
other: datetime
15051531
1506-
def onOffset(self, dt):
1507-
if self.normalize and not _is_normalized(dt):
1508-
return False
1509-
return dt == self.getOffsetOfMonth(dt)
1532+
Returns
1533+
-------
1534+
day: int
1535+
"""
1536+
dim = ccalendar.get_days_in_month(other.year, other.month)
1537+
mend = datetime(other.year, other.month, dim)
1538+
wday = mend.weekday()
1539+
shift_days = (wday - self.weekday) % 7
1540+
return dim - shift_days
15101541

15111542
@property
15121543
def rule_code(self):

0 commit comments

Comments
 (0)