diff --git a/docs/source/examples.rst b/docs/source/examples.rst index f43a76909..5b0b504b3 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -214,6 +214,34 @@ To calculate the number or working days between two specified dates: Here we calculate the number of working days in Q2 2024. +Getting the next/previous holiday +--------------------------------- + +You can request the next/previous holiday of your selected calendar. +The function returns the date and the name of the holiday - exluding today. + +.. code-block:: python + + >>> us_holidays = holidays.US(years=2025) + >>> us_holidays.get_next_holiday() # get the next holiday after today + (datetime.date(2025, 1, 20), 'Martin Luther King Jr. Day') + >>> us_holidays.get_next_holiday(previous=True) # get the previous holiday before today + (datetime.date(2025, 1, 1), "New Year's Day") + >>> us_holidays.is_working_day("2025-02-01") # get the next holiday after a specific date + (datetime.date(2025, 2, 17), "Washington's Birthday") + >>> us_holidays.is_working_day("2025-02-01", previous=True) # get the previous holiday before a specific date + (datetime.date(2025, 1, 20), 'Martin Luther King Jr. Day') + +If no holiday can be found (e.g. because the date would be after the end date / +before the start date), (None, None) is returned. + +.. code-block:: python + + >>> us_holidays.get_next_holiday("2100-12-31") + (None, None) + >>> us_holidays.get_next_holiday("1777-01-01", previous=True) + (None, None) + Date from holiday name ---------------------- diff --git a/holidays/holiday_base.py b/holidays/holiday_base.py index 2db65448c..18b5e77ef 100644 --- a/holidays/holiday_base.py +++ b/holidays/holiday_base.py @@ -532,7 +532,7 @@ def __getattr__(self, name): return lambda name: self._add_holiday( name, _get_nth_weekday_from( - -int(number[0]) if date_direction == "before" else +int(number[0]), + (-int(number[0]) if date_direction == "before" else +int(number[0])), WEEKDAYS[weekday], date(self._year, MONTHS[month], int(day)), ), @@ -686,7 +686,15 @@ def __str__(self) -> str: @property def __attribute_names(self): - return ("country", "expand", "language", "market", "observed", "subdiv", "years") + return ( + "country", + "expand", + "language", + "market", + "observed", + "subdiv", + "years", + ) @cached_property def _entity_code(self): @@ -751,9 +759,11 @@ def _add_special_holidays(self, mapping_names, observed=False): if len(data) == 3: # Special holidays. month, day, name = data self._add_holiday( - self.tr(self.observed_label) % self.tr(name) - if observed - else self.tr(name), + ( + self.tr(self.observed_label) % self.tr(name) + if observed + else self.tr(name) + ), month, day, ) @@ -960,6 +970,23 @@ def get_named( raise AttributeError(f"Unknown lookup type: {lookup}") + def get_next_holiday( + self, start: DateLike = None, previous: bool = False + ) -> Union[tuple[date, str], tuple[None, None]]: + """Return the date and name of the next holiday from provided date + (if previous is False) or the previous holiday (if previous is True). + If no date is given the search starts from current date""" + if not start: + start = datetime.now() + + direction = +1 if not previous else -1 + dt = _timedelta(self.__keytransform__(start), direction) + while not self.get(dt, default=None): + dt = _timedelta(dt, direction) + if (dt.year < self.start_year) or (dt.year > self.end_year): + return None, None + return dt, self.get(dt) + def get_nth_working_day(self, key: DateLike, n: int) -> date: """Return n-th working day from provided date (if n is positive) or n-th working day before provided date (if n is negative). diff --git a/tests/test_holiday_base.py b/tests/test_holiday_base.py index c5563c6b1..438a1a399 100644 --- a/tests/test_holiday_base.py +++ b/tests/test_holiday_base.py @@ -216,7 +216,8 @@ def test_years(self): self.assertSetEqual(hb.years, {2013, 2014, 2015}) self.assertSetEqual( - HolidayBase(years=range(2010, 2016)).years, {2010, 2011, 2012, 2013, 2014, 2015} + HolidayBase(years=range(2010, 2016)).years, + {2010, 2011, 2012, 2013, 2014, 2015}, ) self.assertSetEqual(HolidayBase(years=(2013, 2015, 2015)).years, {2013, 2015}) self.assertSetEqual(HolidayBase(years=(2013.0, 2015.0, 2015.0)).years, {2013, 2015}) @@ -254,7 +255,12 @@ def test_default_category(self): self.assertEqual(ccc.categories, {TestCategories.CustomCategoryClass.default_category}) for name in ("CC Holiday",): self.assertTrue(ccc.get_named(name, lookup="exact")) - for name in ("CC1 Holiday", "CC2 Holiday", "SD_1 CC_1 Holiday", "SD_2 CC Holiday"): + for name in ( + "CC1 Holiday", + "CC2 Holiday", + "SD_1 CC_1 Holiday", + "SD_2 CC Holiday", + ): self.assertFalse(ccc.get_named(name, lookup="exact")) # Default category with subdiv. @@ -269,7 +275,8 @@ def test_no_default_category(self): TestCategories.CustomCategoryClass.default_category = None self.assertRaises(ValueError, lambda: TestCategories.CustomCategoryClass(years=2024)) self.assertRaises( - ValueError, lambda: TestCategories.CustomCategoryClass(years=2024, subdiv="SD_1") + ValueError, + lambda: TestCategories.CustomCategoryClass(years=2024, subdiv="SD_1"), ) # Explicitly set category. @@ -403,7 +410,8 @@ def test_get_list_multiple_subdivisions(self): hb_combined = hb_subdiv_1 + hb_subdiv_2 self.assertEqual( - hb_combined["2021-08-10"], "Subdiv 1 Custom Holiday; Subdiv 2 Custom Holiday" + hb_combined["2021-08-10"], + "Subdiv 1 Custom Holiday; Subdiv 2 Custom Holiday", ) self.assertListEqual( hb_combined.get_list("2021-08-10"), @@ -489,7 +497,12 @@ def test_istartswith(self): hb = CountryStub1(years=2022) for name in ("new year's", "New Year's", "New Year's day"): self.assertListEqual(hb.get_named(name, lookup="istartswith"), [date(2022, 1, 1)]) - for name in ("New Year Day", "New Year holiday", "New Year's Day Holiday", "year"): + for name in ( + "New Year Day", + "New Year holiday", + "New Year's Day Holiday", + "year", + ): self.assertListEqual(hb.get_named(name, lookup="istartswith"), []) self.assertListEqual( hb.get_named("independence day", lookup="istartswith"), [date(2022, 7, 4)] @@ -506,7 +519,12 @@ def test_startswith(self): hb = CountryStub1(years=2022) for name in ("New Year's", "New Year"): self.assertListEqual(hb.get_named(name, lookup="startswith"), [date(2022, 1, 1)]) - for name in ("New Year Day", "New Year Holiday", "New Year's Day Holiday", "year"): + for name in ( + "New Year Day", + "New Year Holiday", + "New Year's Day Holiday", + "year", + ): self.assertListEqual(hb.get_named(name, lookup="startswith"), []) self.assertListEqual( hb.get_named("Independence Day", lookup="startswith"), [date(2022, 7, 4)] @@ -631,7 +649,8 @@ def test_add_country(self): self.hb_combined = CountryStub1(years=2014, subdiv="Subdiv 1") self.hb_combined += CountryStub1(years=2014, subdiv="Subdiv 2") self.assertEqual( - self.hb_combined["2014-08-10"], "Subdiv 1 Custom Holiday; Subdiv 2 Custom Holiday" + self.hb_combined["2014-08-10"], + "Subdiv 1 Custom Holiday; Subdiv 2 Custom Holiday", ) self.assertRaises(TypeError, lambda: self.hb_1 + {}) @@ -1189,3 +1208,79 @@ def test_get_working_days_count(self): self.assertEqual(self.hb.get_working_days_count("2024-04-29", "2024-05-04"), 3) self.assertEqual(self.hb.get_working_days_count("2024-04-29", "2024-05-05"), 3) self.assertEqual(self.hb.get_working_days_count("2024-04-29", "2024-05-06"), 4) + + +class TestNextHoliday(unittest.TestCase): + def setUp(self): + self.thisYear = datetime.now().year + self.nextYear = self.thisYear + 1 + self.previousYear = self.thisYear - 1 + self.hb = CountryStub3(years=self.thisYear) + self.nextLaborDayYear = ( + self.thisYear + if datetime.now().date() < self.hb.get_named("Custom May 1st Holiday")[0] + else self.nextYear + ) + self.previousLaborDayYear = ( + self.thisYear + if datetime.now().date() > self.hb.get_named("Custom May 1st Holiday")[0] + else self.previousYear + ) + + def test_get_next_holiday_forward(self): + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-01-01"), + (date(self.thisYear, 5, 1), "Custom May 1st Holiday"), + ) + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-04-30"), + (date(self.thisYear, 5, 1), "Custom May 1st Holiday"), + ) + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-05-01"), + (date(self.thisYear, 5, 2), "Custom May 2nd Holiday"), + ) + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-05-02"), + (date(self.nextYear, 5, 1), "Custom May 1st Holiday"), + ) + + self.assertTrue( + self.hb.get_next_holiday() + in [ + (date(self.nextLaborDayYear, 5, 1), "Custom May 1st Holiday"), + (date(self.nextLaborDayYear, 5, 2), "Custom May 2nd Holiday"), + ] + ) + + def test_get_next_holiday_reverse(self): + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-12-31", previous=True), + (date(self.thisYear, 5, 2), "Custom May 2nd Holiday"), + ) + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-05-02", previous=True), + (date(self.thisYear, 5, 1), "Custom May 1st Holiday"), + ) + self.assertEqual( + self.hb.get_next_holiday(f"{self.thisYear}-04-30", previous=True), + (date(self.previousYear, 5, 2), "Custom May 2nd Holiday"), + ) + + self.assertTrue( + self.hb.get_next_holiday(previous=True) + in [ + (date(self.previousLaborDayYear, 5, 2), "Custom May 2nd Holiday"), + (date(self.thisYear, 5, 1), "Custom May 1st Holiday"), + ] + ) + + def test_get_next_holiday_corner_cases(self): + from holidays.countries.ukraine import UA + + ua = UA() + # check for date before start of calendar + self.assertEqual(ua.get_next_holiday("1991-01-01", True), (None, None)) + + # check for date after end of calendar + self.assertEqual(ua.get_next_holiday("2022-03-08"), (None, None))