Skip to content

Fix bugs in WeekOfMonth.apply, Week.onOffset #18875

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 23, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side issue. _localize_pydatetime is in the wrong place (its in tslib) should be in conversion.pyx. And It shouldn't just return on None as well, and needs a better doc-string. can you add to list.



# ---------------------------------------------------------------------
# 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