Skip to content

Commit

Permalink
Fix bugs in WeekOfMonth.apply, Week.onOffset (pandas-dev#18875)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbrockmendel authored and jreback committed Dec 23, 2017
1 parent b9cc821 commit 175cc4f
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 52 deletions.
3 changes: 2 additions & 1 deletion doc/source/whatsnew/v0.23.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,8 @@ Conversion
- 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`)
- Bug in :meth:`Series.astype` and ``Categorical.astype()`` where an existing categorical data does not get updated (:issue:`10696`, :issue:`18593`)
- 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`)
- Bug in :class:`Timestamp` where comparison with an array of ``Timestamp`` objects would result in a ``RecursionError`` (:issue:`15183`)
- Bug in :class:`WeekOfMonth` and class:`Week` where addition and subtraction did not roll correctly (:issue:`18510`,:issue:`18672`,:issue:`18864`)


Indexing
Expand Down Expand Up @@ -361,4 +363,3 @@ Other
^^^^^

- Improved error message when attempting to use a Python keyword as an identifier in a ``numexpr`` backed query (:issue:`18221`)
- Bug in :class:`Timestamp` where comparison with an array of ``Timestamp`` objects would result in a ``RecursionError`` (:issue:`15183`)
34 changes: 34 additions & 0 deletions pandas/tests/tseries/offsets/test_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3147,3 +3147,37 @@ def test_require_integers(offset_types):
cls = offset_types
with pytest.raises(ValueError):
cls(n=1.5)


def test_weeks_onoffset():
# GH#18510 Week with weekday = None, normalize = False should always
# be onOffset
offset = Week(n=2, weekday=None)
ts = Timestamp('1862-01-13 09:03:34.873477378+0210', tz='Africa/Lusaka')
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow

# negative n
offset = Week(n=2, weekday=None)
ts = Timestamp('1856-10-24 16:18:36.556360110-0717', tz='Pacific/Easter')
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow


def test_weekofmonth_onoffset():
# GH#18864
# Make sure that nanoseconds don't trip up onOffset (and with it apply)
offset = WeekOfMonth(n=2, week=2, weekday=0)
ts = Timestamp('1916-05-15 01:14:49.583410462+0422', tz='Asia/Qyzylorda')
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow

# negative n
offset = WeekOfMonth(n=-3, week=1, weekday=0)
ts = Timestamp('1980-12-08 03:38:52.878321185+0500', tz='Asia/Oral')
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow
133 changes: 82 additions & 51 deletions pandas/tseries/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ def wrapper(self, other):
return wrapper


def shift_day(other, days):
"""
Increment the datetime `other` by the given number of days, retaining
the time-portion of the datetime. For tz-naive datetimes this is
equivalent to adding a timedelta. For tz-aware datetimes it is similar to
dateutil's relativedelta.__add__, but handles pytz tzinfo objects.
Parameters
----------
other : datetime or Timestamp
days : int
Returns
-------
shifted: datetime or Timestamp
"""
if other.tzinfo is None:
return other + timedelta(days=days)

tz = other.tzinfo
naive = other.replace(tzinfo=None)
shifted = naive + timedelta(days=days)
return tslib._localize_pydatetime(shifted, tz)


# ---------------------------------------------------------------------
# DateOffset

Expand Down Expand Up @@ -1342,6 +1367,8 @@ def apply_index(self, i):
def onOffset(self, dt):
if self.normalize and not _is_normalized(dt):
return False
elif self.weekday is None:
return True
return dt.weekday() == self.weekday

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


class WeekOfMonth(DateOffset):
class _WeekOfMonthMixin(object):
"""Mixin for methods common to WeekOfMonth and LastWeekOfMonth"""
@apply_wraps
def apply(self, other):
compare_day = self._get_offset_day(other)

months = self.n
if months > 0 and compare_day > other.day:
months -= 1
elif months <= 0 and compare_day < other.day:
months += 1

shifted = shift_month(other, months, 'start')
to_day = self._get_offset_day(shifted)
return shift_day(shifted, to_day - shifted.day)

def onOffset(self, dt):
if self.normalize and not _is_normalized(dt):
return False
return dt.day == self._get_offset_day(dt)


class WeekOfMonth(_WeekOfMonthMixin, DateOffset):
"""
Describes monthly dates like "the Tuesday of the 2nd week of each month"
Expand Down Expand Up @@ -1400,34 +1449,23 @@ def __init__(self, n=1, normalize=False, week=None, weekday=None):

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

@apply_wraps
def apply(self, other):
base = other
offsetOfMonth = self.getOffsetOfMonth(other)

months = self.n
if months > 0 and offsetOfMonth > other:
months -= 1
elif months <= 0 and offsetOfMonth < other:
months += 1

other = self.getOffsetOfMonth(shift_month(other, months, 'start'))
other = datetime(other.year, other.month, other.day, base.hour,
base.minute, base.second, base.microsecond)
return other
def _get_offset_day(self, other):
"""
Find the day in the same month as other that has the same
weekday as self.weekday and is the self.week'th such day in the month.
def getOffsetOfMonth(self, dt):
w = Week(weekday=self.weekday)
d = datetime(dt.year, dt.month, 1, tzinfo=dt.tzinfo)
# TODO: Is this DST-safe?
d = w.rollforward(d)
return d + timedelta(weeks=self.week)
Parameters
----------
other: datetime
def onOffset(self, dt):
if self.normalize and not _is_normalized(dt):
return False
d = datetime(dt.year, dt.month, dt.day, tzinfo=dt.tzinfo)
return d == self.getOffsetOfMonth(dt)
Returns
-------
day: int
"""
mstart = datetime(other.year, other.month, 1)
wday = mstart.weekday()
shift_days = (self.weekday - wday) % 7
return 1 + shift_days + self.week * 7

@property
def rule_code(self):
Expand All @@ -1448,7 +1486,7 @@ def _from_name(cls, suffix=None):
return cls(week=week, weekday=weekday)


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

self.kwds = {'weekday': weekday}

@apply_wraps
def apply(self, other):
offsetOfMonth = self.getOffsetOfMonth(other)

months = self.n
if months > 0 and offsetOfMonth > other:
months -= 1
elif months <= 0 and offsetOfMonth < other:
months += 1

return self.getOffsetOfMonth(shift_month(other, months, 'start'))
def _get_offset_day(self, other):
"""
Find the day in the same month as other that has the same
weekday as self.weekday and is the last such day in the month.
def getOffsetOfMonth(self, dt):
m = MonthEnd()
d = datetime(dt.year, dt.month, 1, dt.hour, dt.minute,
dt.second, dt.microsecond, tzinfo=dt.tzinfo)
eom = m.rollforward(d)
# TODO: Is this DST-safe?
w = Week(weekday=self.weekday)
return w.rollback(eom)
Parameters
----------
other: datetime
def onOffset(self, dt):
if self.normalize and not _is_normalized(dt):
return False
return dt == self.getOffsetOfMonth(dt)
Returns
-------
day: int
"""
dim = ccalendar.get_days_in_month(other.year, other.month)
mend = datetime(other.year, other.month, dim)
wday = mend.weekday()
shift_days = (wday - self.weekday) % 7
return dim - shift_days

@property
def rule_code(self):
Expand Down

0 comments on commit 175cc4f

Please sign in to comment.