Repository: pandas-dev/pandas

Issue:
Week.onOffset looks fishy
A Week offset with weekday=None behaves very much like a Tick offset.  The main difference is how they handle `onOffset`.  Assuming `normalize` is False, the `Week` object will always return `week.onOffset(other)` --> `False` while the `Day` object will always return `day.onOffset(other)` --> `True`.

Is this intentional?


Discussion:
@jbrockmendel : Would you mind posting a small code sample for this just for reference?
```
In [2]: week = pd.offsets.Week()
In [3]: day7 = pd.offsets.Day(n=7)
In [5]: ts = pd.Timestamp.now()
In [6]: ts
Out[6]: Timestamp('2017-11-27 13:03:35.592386')
In [7]: ts + day7
Out[7]: Timestamp('2017-12-04 13:03:35.592386')
In [8]: ts + week
Out[8]: Timestamp('2017-12-04 13:03:35.592386')
In [9]: week.onOffset(ts)
Out[9]: False
In [10]: day7.onOffset(ts)
Out[10]: True
In [12]: day7.rollforward(ts)
Out[12]: Timestamp('2017-11-27 13:03:35.592386')
In [13]: week.rollforward(ts)
Out[13]: Timestamp('2017-12-04 13:03:35.592386')
```

It isn't that the Week.onOffset behavior is _wrong_ per se, just that I don't see why it behaves this way.  In every other way it behaves like a Tick offset.
@jbrockmendel : Indeed, it's difficult for me to say that it's wrong when there is **ZERO** documentation on that function. :smile: (in fact, if you would like to add documentation to that method, be my guest!)

That being said, I do see what you mean re: consistency.  @jreback @jorisvandenbossche thoughts?
@jreback is it the case that `offset.onOffset(dt)` should always be the same as (the base class implementation) `(dt + offset) - offset == dt`?  If so, this is a bug and should be resolved before #18738.
@gfyoung mind changing the tag from Timedelta to Frequencies?
@jbrockmendel : done

Resolving patch:
diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py
--- a/pandas/tseries/offsets.py
+++ b/pandas/tseries/offsets.py
@@ -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
 
@@ -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
@@ -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"
 
@@ -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):
@@ -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"
@@ -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):


Test patch:
diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py
--- a/pandas/tests/tseries/offsets/test_offsets.py
+++ b/pandas/tests/tseries/offsets/test_offsets.py
@@ -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