Skip to content
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

Implement HolidayBase::get_closest_holiday functionality #2211

Open
wants to merge 10 commits into from
47 changes: 47 additions & 0 deletions docs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,53 @@ To calculate the number or working days between two specified dates:

Here we calculate the number of working days in Q2 2024.

Getting the closest (next or previous) holiday
----------------------------------------------

You can fetch next or previous holiday for a target date of your selected calendar.
The function returns found holiday's date and name excluding the target date.

Get the next holiday for the current date:

.. code-block:: python

>>> us_holidays = holidays.US(years=2025)
>>> us_holidays.get_closest_holiday()
(datetime.date(2025, 1, 20), 'Martin Luther King Jr. Day')

Get the previous holiday for the current date:

.. code-block:: python

>>> us_holidays = holidays.US(years=2025)
>>> us_holidays.get_closest_holiday(direction="backward")
(datetime.date(2025, 1, 1), "New Year's Day")

Get the next holiday for a specific target date:

.. code-block:: python

>>> us_holidays = holidays.US(years=2025)
>>> us_holidays.get_closest_holiday("2025-02-01")
(datetime.date(2025, 2, 17), "Washington's Birthday")

Get the previous holiday for a specific target date:

.. code-block:: python

>>> us_holidays = holidays.US(years=2025)
>>> us_holidays.get_closest_holiday("2025-02-01", direction="backward")
(datetime.date(2025, 1, 20), 'Martin Luther King Jr. Day')

If the closest holiday cannot be found None is returned.

.. code-block:: python

>>> print(us_holidays.get_closest_holiday("2100-12-31"))
None
>>> print(us_holidays.get_closest_holiday("1777-01-01", direction="backward"))
None

Date from holiday name
----------------------

Expand Down
33 changes: 32 additions & 1 deletion holidays/holiday_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@

import copy
import warnings
from bisect import bisect_left, bisect_right
from calendar import isleap
from collections.abc import Iterable
from datetime import date, datetime, timedelta, timezone
from functools import cached_property
from gettext import gettext, translation
from pathlib import Path
from typing import Any, Dict, Optional, Union, cast
from typing import Any, Dict, Literal, Optional, Union, cast

from dateutil.parser import parse

Expand Down Expand Up @@ -985,6 +986,36 @@ def get_named(

raise AttributeError(f"Unknown lookup type: {lookup}")

def get_closest_holiday(
KJhellico marked this conversation as resolved.
Show resolved Hide resolved
self,
target_date: DateLike = None,
direction: Literal["forward", "backward"] = "forward",
) -> Optional[tuple[date, str]]:
"""Return the date and name of the next holiday for a target_date
if direction is "forward" or the previous holiday if direction is "backward".
If target_date is not provided the current date will be used by default."""

if direction not in {"backward", "forward"}:
raise AttributeError(f"Unknown direction: {direction}")

dt = self.__keytransform__(target_date or datetime.now().date())
if direction == "forward" and (next_year := dt.year + 1) not in self.years:
self._populate(next_year)
elif direction == "backward" and (previous_year := dt.year - 1) not in self.years:
self._populate(previous_year)

sorted_dates = sorted(self.keys())
position = (
bisect_right(sorted_dates, dt)
if direction == "forward"
else bisect_left(sorted_dates, dt) - 1
)
if 0 <= position < len(sorted_dates):
dt = sorted_dates[position]
return dt, self[dt]

return None

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).
Expand Down
110 changes: 110 additions & 0 deletions tests/test_holiday_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from holidays.calendars.gregorian import JAN, FEB, OCT, DEC, MON, TUE, SAT, SUN
from holidays.constants import HOLIDAY_NAME_DELIMITER, OPTIONAL, PUBLIC, SCHOOL
from holidays.countries import UA, US
from holidays.groups.christian import ChristianHolidays
from holidays.groups.custom import StaticHolidays
from holidays.holiday_base import HolidayBase
Expand Down Expand Up @@ -1189,3 +1190,112 @@ 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 TestClosestHoliday(unittest.TestCase):
def setUp(self):
self.current_year = datetime.now().year
self.next_year = self.current_year + 1
self.previous_year = self.current_year - 1
self.hb = CountryStub3(years=self.current_year)
self.next_labor_day_year = (
self.current_year
if datetime.now().date() < self.hb.get_named("Custom May 1st Holiday")[0]
else self.next_year
)
self.previous_labor_day_year = (
self.current_year
if datetime.now().date() > self.hb.get_named("Custom May 1st Holiday")[0]
else self.previous_year
)

def test_get_closest_holiday_forward(self):
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-01-01"),
(date(self.current_year, 5, 1), "Custom May 1st Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-04-30"),
(date(self.current_year, 5, 1), "Custom May 1st Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-05-01"),
(date(self.current_year, 5, 2), "Custom May 2nd Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-05-02"),
(date(self.next_year, 5, 1), "Custom May 1st Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.next_year}-01-01"),
(date(self.next_year, 5, 1), "Custom May 1st Holiday"),
)

self.assertIn(
self.hb.get_closest_holiday(),
[
(date(self.next_labor_day_year, 5, 1), "Custom May 1st Holiday"),
(date(self.next_labor_day_year, 5, 2), "Custom May 2nd Holiday"),
],
)

def test_get_closest_holiday_backward(self):
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-12-31", direction="backward"),
(date(self.current_year, 5, 2), "Custom May 2nd Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-05-02", direction="backward"),
(date(self.current_year, 5, 1), "Custom May 1st Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.current_year}-04-30", direction="backward"),
(date(self.previous_year, 5, 2), "Custom May 2nd Holiday"),
)
self.assertEqual(
self.hb.get_closest_holiday(f"{self.previous_year}-12-31", direction="backward"),
(date(self.previous_year, 5, 2), "Custom May 2nd Holiday"),
)

self.assertIn(
self.hb.get_closest_holiday(direction="backward"),
[
(date(self.previous_labor_day_year, 5, 2), "Custom May 2nd Holiday"),
(date(self.current_year, 5, 1), "Custom May 1st Holiday"),
],
)

def test_get_closest_holiday_corner_cases(self):
us = US()
# check for date before start of calendar
self.assertIsNone(us.get_closest_holiday("1777-01-01", direction="backward"))

# check for date after end of calendar
self.assertIsNone(us.get_closest_holiday("2100-12-31"))

def test_get_closest_holiday_after_empty_year(self):
ua = UA(years=2025)
# check for date if a year has no holidays
ua._add_holiday_jan_1("Custom holiday")
self.assertEqual(
ua.get_closest_holiday("2022-03-08"), (date(2025, 1, 1), "Custom holiday")
)

def test_get_closest_holiday_unsorted_calendars(self):
us_calendar = US(years=2024)

self.assertEqual(
us_calendar.get_closest_holiday(date(2024, 2, 1)),
(date(2024, 2, 19), "Washington's Birthday"),
)

# check for date before start of calendar
self.assertEqual(
us_calendar.get_closest_holiday(date(2024, 2, 1), direction="backward"),
(date(2024, 1, 15), "Martin Luther King Jr. Day"),
)

def test_get_closest_holiday_invalid_direction(self):
self.assertRaises(
AttributeError, lambda: HolidayBase().get_closest_holiday(direction="invalid")
)