Repository: pandas-dev/pandas
Pull request: Fix bug in calculation of holidays
Closes #31415

- [x] closes #xxxx
- [x] tests added / passed
- [x] passes `black pandas`
- [x] passes `git diff upstream/master -u -- "*.py" | flake8 --diff`
- [x] whatsnew entry


Problem statement:
Bug in AbstractHolidayCalendar.holidays
...when there are no rules.

```python
from pandas.tseries.holiday import AbstractHolidayCalendar

class ExampleCalendar(AbstractHolidayCalendar):
    pass

cal = ExampleCalendar()
```
```python
In [58]: cal.holidays(pd.Timestamp('01-Jan-2020'), pd.Timestamp('01-Jan-2021'))
Traceback (most recent call last):

  File "<ipython-input-58-022244d4e794>", line 1, in <module>
    cal.holidays(pd.Timestamp('01-Jan-2020'), pd.Timestamp('01-Jan-2021'))

  File "C:\Users\dhirschf\envs\dev\lib\site-packages\pandas\tseries\holiday.py", line 422, in holidays
    self._cache = (start, end, holidays.sort_index())

AttributeError: 'NoneType' object has no attribute 'sort_index'

In [59]: pd.__version__
Out[59]: '0.25.3'
```

Hints from issue discussion:
As can be seen below:
https://github.com/pandas-dev/pandas/blob/ec0996c6751326eed17a0bb456fe1c550689a618/pandas/tseries/holiday.py#L409-L421

when `self.rules` is empty
```python
In [61]: cal.rules
Out[61]: []
```
You drop through the loop to L421:
```python
self._cache = (start, end, holidays.sort_index()) 
```
where `holidays` remains unchanged from being set to `None` before the loop on L409.

Commit message: Fix bug in calculation of holidays

Closes #31415

Patch to review:
diff --git a/doc/source/whatsnew/v1.1.0.rst b/doc/source/whatsnew/v1.1.0.rst
index dc7edd8db662e..dc17cddd32df9 100644
--- a/doc/source/whatsnew/v1.1.0.rst
+++ b/doc/source/whatsnew/v1.1.0.rst
@@ -223,6 +223,8 @@ Other
 - Appending a dictionary to a :class:`DataFrame` without passing ``ignore_index=True`` will raise ``TypeError: Can only append a dict if ignore_index=True``
   instead of ``TypeError: Can only append a Series if ignore_index=True or if the Series has a name`` (:issue:`30871`)
 - Set operations on an object-dtype :class:`Index` now always return object-dtype results (:issue:`31401`)
+- Bug in :meth:`AbstractHolidayCalendar.holidays` when no rules were defined (:issue:`31415`)
+-
 
 .. ---------------------------------------------------------------------------
 
diff --git a/pandas/tests/tseries/holiday/test_calendar.py b/pandas/tests/tseries/holiday/test_calendar.py
index 5b4a7c74b1af1..9c53bfc70f3c5 100644
--- a/pandas/tests/tseries/holiday/test_calendar.py
+++ b/pandas/tests/tseries/holiday/test_calendar.py
@@ -98,3 +98,14 @@ class testCalendar(AbstractHolidayCalendar):
     Sat_before_Labor_Day_2031 = to_datetime("2031-08-30")
     next_working_day = Sat_before_Labor_Day_2031 + 0 * workDay
     assert next_working_day == to_datetime("2031-09-02")
+
+
+def test_no_holidays_calendar():
+    # Test for issue #31415
+
+    class NoHolidaysCalendar(AbstractHolidayCalendar):
+        pass
+
+    cal = NoHolidaysCalendar()
+    holidays = cal.holidays(Timestamp("01-Jan-2020"), Timestamp("01-Jan-2021"))
+    assert holidays.empty
diff --git a/pandas/tseries/holiday.py b/pandas/tseries/holiday.py
index 62d7c26b590cc..fe30130e87c01 100644
--- a/pandas/tseries/holiday.py
+++ b/pandas/tseries/holiday.py
@@ -7,7 +7,7 @@
 
 from pandas.errors import PerformanceWarning
 
-from pandas import DateOffset, Series, Timestamp, date_range
+from pandas import DateOffset, DatetimeIndex, Series, Timestamp, concat, date_range
 
 from pandas.tseries.offsets import Day, Easter
 
@@ -406,17 +406,14 @@ def holidays(self, start=None, end=None, return_name=False):
         start = Timestamp(start)
         end = Timestamp(end)
 
-        holidays = None
         # If we don't have a cache or the dates are outside the prior cache, we
         # get them again
         if self._cache is None or start < self._cache[0] or end > self._cache[1]:
-            for rule in self.rules:
-                rule_holidays = rule.dates(start, end, return_name=True)
-
-                if holidays is None:
-                    holidays = rule_holidays
-                else:
-                    holidays = holidays.append(rule_holidays)
+            holidays = [rule.dates(start, end, return_name=True) for rule in self.rules]
+            if holidays:
+                holidays = concat(holidays)
+            else:
+                holidays = Series(index=DatetimeIndex([]), dtype=object)
 
             self._cache = (start, end, holidays.sort_index())
 


Code review comments:
diff_hunk: @@ -98,3 +98,14 @@ class testCalendar(AbstractHolidayCalendar):
     Sat_before_Labor_Day_2031 = to_datetime("2031-08-30")
     next_working_day = Sat_before_Labor_Day_2031 + 0 * workDay
     assert next_working_day == to_datetime("2031-09-02")
+
+
+def test_no_holidays_calendar():
+    # Test for issue #31415
+
+    class NoHolidaysCalendar(AbstractHolidayCalendar):
+        pass
+
+    cal = NoHolidaysCalendar()
+    holidays = cal.holidays(Timestamp("01-Jan-2020"), Timestamp("01-Jan-2021"))
+    assert holidays.empty
path: pandas/tests/tseries/holiday/test_calendar.py
text: @user1:
can you use tm.assert_series_equal here and construct an empty Series with a DTI?