Skip to content

Commit

Permalink
Implement holidays date range limit
Browse files Browse the repository at this point in the history
      - Add `HolidayBase::since` and `HolidayBase::until` parameters
      - Refactor types into a separate module
      - Extract DateLike conversion to date to a separate module
      - Update documentation with examples
      - Fix specific observance logic cases for some countries
      - Update `test_utils::TestYears` tests
  • Loading branch information
arkid15r committed Jun 18, 2024
1 parent 5c84b47 commit c7a14cd
Show file tree
Hide file tree
Showing 20 changed files with 390 additions and 61 deletions.
34 changes: 34 additions & 0 deletions docs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,40 @@ fly and the holiday list will be adjusted accordingly:
>> date(2012, 1, 2) in us_holidays
True
Date range limits: since and until parameters
---------------------------------------------

If you need to retrieve a set of holidays limited by specific date(s) you can
use :py:attr:`since` and/or :py:attr:`until` parameters. Both since/until
parameters accept :py:class:`DateLike` objects. Please note that the
resulting set will include holidays falling on :py:attr:`since` and
:py:attr:`until` dates too (inclusive range).

Here is an example of retrieving US holidays for January of 2024:

.. code-block:: python
>>> us_holidays = holidays.US(years=2024, since="2024-01-01", until="2024-01-31")
>>> print(us_holidays)
{datetime.date(2024, 1, 1): "New Year's Day", datetime.date(2024, 1, 15): 'Martin Luther King Jr. Day'}
>>> "2024-07-04" in us_holidays
False
Here is another example of using :py:attr:`since` parameter to get holidays for the second half of 2024:

.. code-block:: python
>>> from datetime import date
>>> for dt, name in sorted(holidays.US(years=2024, since=date(2024, 7, 1)).items()):
>>> print(dt, name)
2024-07-04 Independence Day
2024-09-02 Labor Day
2024-10-14 Columbus Day
2024-11-11 Veterans Day
2024-11-28 Thanksgiving
2024-12-25 Christmas Day
Language support
----------------
To change the language translation, you can set the language explicitly.
Expand Down
1 change: 1 addition & 0 deletions holidays/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from holidays.holiday_base import *
from holidays.registry import EntityLoader
from holidays.types import DateLike # noqa: F401
from holidays.utils import *

__version__ = "0.52"
Expand Down
3 changes: 3 additions & 0 deletions holidays/countries/angola.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ def _is_observed(self, dt: date) -> bool:
return dt >= date(1996, SEP, 27)

def _add_observed(self, dt: date, **kwargs) -> Tuple[bool, Optional[date]]:
if dt is None:
return False, None

# As per Law # #11/18, from 2018/9/10, when public holiday falls on Tuesday or Thursday,
# the Monday or Friday is also a holiday.
kwargs.setdefault(
Expand Down
2 changes: 1 addition & 1 deletion holidays/countries/bulgaria.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __init__(self, *args, **kwargs):

def _populate_observed(self, dts: Set[date], excluded_names: Set[str]) -> None:
for dt in sorted(dts):
if not self._is_observed(dt):
if dt is None or not self._is_observed(dt):
continue
for name in self.get_list(dt):
if name not in excluded_names:
Expand Down
5 changes: 3 additions & 2 deletions holidays/countries/cambodia.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ def _populate_public_holidays(self):
if self._year in sangkranta_years_apr_14
else self._add_holiday_apr_13(sangkranta)
)
self._add_holiday(sangkranta, _timedelta(dt, +1))
self._add_holiday(sangkranta, _timedelta(dt, +2))
if dt is not None:
self._add_holiday(sangkranta, _timedelta(dt, +1))
self._add_holiday(sangkranta, _timedelta(dt, +2))

# ទិវាពលកម្មអន្តរជាតិ
# Status: In-Use.
Expand Down
5 changes: 3 additions & 2 deletions holidays/countries/finland.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ def _populate_public_holidays(self):
else:
dt = self._add_holiday_jun_23(name)

# Midsummer Day.
self._add_holiday(tr("Juhannuspäivä"), _timedelta(dt, +1))
if dt is not None:
# Midsummer Day.
self._add_holiday(tr("Juhannuspäivä"), _timedelta(dt, +1))

# All Saints' Day.
name = tr("Pyhäinpäivä")
Expand Down
4 changes: 3 additions & 1 deletion holidays/countries/japan.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ def _populate_observed(self, dts: Set[date]) -> None:
# When a national holiday falls on Sunday, next working day
# shall become a public holiday (振替休日) - substitute holidays.
for dt in sorted(dts):
if dt is None:
continue
is_observed, dt_observed = self._add_observed(
dt,
# Substitute Holiday.
Expand All @@ -67,7 +69,7 @@ def _populate_observed(self, dts: Set[date]) -> None:
# A weekday between national holidays becomes
# a holiday too (国民の休日) - national holidays.
for dt in dts:
if _timedelta(dt, +2) not in dts:
if dt is None or _timedelta(dt, +2) not in dts:
continue
dt_observed = _timedelta(dt, +1)
if self._is_sunday(dt_observed) or dt_observed in dts:
Expand Down
3 changes: 3 additions & 0 deletions holidays/countries/jersey.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ def __init__(self, *args, **kwargs):
ObservedHolidayBase.__init__(self, *args, **kwargs)

def _add_observed(self, dt: date, **kwargs) -> Tuple[bool, Optional[date]]:
if dt is None:
return False, None

# Prior to 2004, in-lieu are only given for Sundays.
# https://www.jerseylaw.je/laws/enacted/Pages/RO-123-2004.aspx
kwargs.setdefault(
Expand Down
2 changes: 1 addition & 1 deletion holidays/countries/moldova.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def _populate_public_holidays(self):
tr("Ziua Victoriei și a comemorării eroilor căzuţi pentru Independenţa Patriei")
)

if self._year >= 2017:
if self._year >= 2017 and may_9 is not None:
# Europe Day.
self._add_holiday(tr("Ziua Europei"), may_9)

Expand Down
5 changes: 4 additions & 1 deletion holidays/countries/south_korea.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def __init__(self, *args, **kwargs):

def _populate_observed(self, dts: Set[date], three_day_holidays: Dict[date, str]) -> None:
for dt in sorted(dts.union(three_day_holidays.keys())):
if not self._is_observed(dt):
if dt is None or not self._is_observed(dt):
continue
dt_observed = self._get_observed_date(
dt, SUN_TO_NEXT_WORKDAY if dt in three_day_holidays else SAT_SUN_TO_NEXT_WORKDAY
Expand All @@ -125,6 +125,9 @@ def append_observed(dt: date, since: int):
dts_observed.add(dt)

def add_three_day_holiday(dt: date, name: str):
if dt is None:
return None

name = self.tr(name)
for dt_alt in (
# The day preceding %s.
Expand Down
5 changes: 3 additions & 2 deletions holidays/countries/sweden.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@ def _populate_public_holidays(self):
else self._add_holiday_jun_23(name)
)

# Midsummer Day.
self._add_holiday(tr("Midsommardagen"), _timedelta(dt, +1))
if dt is not None:
# Midsummer Day.
self._add_holiday(tr("Midsommardagen"), _timedelta(dt, +1))

# All Saints' Day.
self._add_holiday_1st_sat_from_oct_31(tr("Alla helgons dag"))
Expand Down
2 changes: 2 additions & 0 deletions holidays/countries/taiwan.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ def _populate_observed(
if self._year < since:
return None
for dt in sorted(dts):
if dt is None:
continue
for name in self.get_list(dt):
self._add_observed(dt, name, rule)

Expand Down
46 changes: 46 additions & 0 deletions holidays/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,52 @@
# Website: https://github.com/vacanza/python-holidays
# License: MIT (see LICENSE file)

from datetime import date, datetime, timezone

from dateutil.parser import parse

from holidays.types import DateLike


def _convert_to_date(dt: DateLike) -> date:
"""Convert value to date.
:param dt:
A value that should be converted into a date.
:return:
A date created based on the provided value.
"""

# Attempt to catch `date` and `str` type keys first.
# Using `type()`` instead of `isinstance()` here to skip date subclasses.
# Key is `date`.
if type(dt) is date:
return dt

# Key is `str` instance.
elif isinstance(dt, str):
try:
return parse(dt).date()
except (OverflowError, ValueError):
raise ValueError(f"Cannot parse date from string '{dt}'")

# Key is `datetime` instance.
elif isinstance(dt, datetime):
return dt.date()

# Must go after the `isinstance(key, datetime)` check as datetime is `date` subclass.
elif isinstance(dt, date):
return dt

# Key is `float` or `int` instance.
elif isinstance(dt, (float, int)):
return datetime.fromtimestamp(dt, timezone.utc).date()

# Key is not supported.
else:
raise TypeError(f"Cannot convert type '{type(dt)}' to date.")


def _normalize_arguments(cls, value):
"""Normalize arguments.
Expand Down
84 changes: 37 additions & 47 deletions holidays/holiday_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@
import copy
import warnings
from calendar import isleap
from datetime import date, datetime, timedelta, timezone
from datetime import date, datetime, timedelta
from functools import cached_property
from gettext import gettext, translation
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, cast

from dateutil.parser import parse
from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast

from holidays.calendars.gregorian import (
MON,
Expand All @@ -39,17 +37,8 @@
WEEKDAYS,
)
from holidays.constants import HOLIDAY_NAME_DELIMITER, PUBLIC
from holidays.helpers import _normalize_arguments, _normalize_tuple

CategoryArg = Union[str, Iterable[str]]
DateArg = Union[date, Tuple[int, int]]
DateLike = Union[date, datetime, str, float, int]
SpecialHoliday = Union[Tuple[int, int, str], Tuple[Tuple[int, int, str], ...]]
SubstitutedHoliday = Union[
Union[Tuple[int, int, int, int], Tuple[int, int, int, int, int]],
Tuple[Union[Tuple[int, int, int, int], Tuple[int, int, int, int, int]], ...],
]
YearArg = Union[int, Iterable[int]]
from holidays.helpers import _convert_to_date, _normalize_arguments, _normalize_tuple
from holidays.types import CategoryArg, DateLike, SpecialHoliday, SubstitutedHoliday, YearArg


class HolidayBase(Dict[date, str]):
Expand Down Expand Up @@ -255,6 +244,8 @@ def __init__(
state: Optional[str] = None, # Deprecated.
language: Optional[str] = None,
categories: Optional[CategoryArg] = None,
since: Optional[DateLike] = None,
until: Optional[DateLike] = None,
) -> None:
"""
:param years:
Expand Down Expand Up @@ -288,6 +279,14 @@ def __init__(
:param categories:
Requested holiday categories.
:param since:
The date limiting the lower bound of holidays date range.
The holidays falling on the `since` date will be included too.
:param until:
The date limiting the upper bound of holidays date range.
The holidays falling on the `until` date will be included too.
:return:
A :class:`HolidayBase` object matching the **country**.
"""
Expand Down Expand Up @@ -350,6 +349,13 @@ def __init__(
"and `substituted_date_format` attributes set."
)

self.since_date = _convert_to_date(since) if since is not None else since
self.until_date = _convert_to_date(until) if until is not None else until
if self.since_date and self.until_date and self.until_date < self.since_date:
raise ValueError(
"The holidays date range until date mustn't be earlier than since date."
)

self.categories = categories
self.expand = expand
self.has_special_holidays = getattr(self, "has_special_holidays", False)
Expand Down Expand Up @@ -581,37 +587,14 @@ def __keytransform__(self, key: DateLike) -> date:
to :class:`datetime.date`, which is how it's stored by the class."""

# Try to catch `date` and `str` type keys first.
# Using type() here to skip date subclasses.
# Key is `date`.
if type(key) is date:
dt = key

# Key is `str` instance.
elif isinstance(key, str):
try:
dt = parse(key).date()
except (OverflowError, ValueError):
raise ValueError(f"Cannot parse date from string '{key}'")

# Key is `datetime` instance.
elif isinstance(key, datetime):
dt = key.date()

# Must go after the `isinstance(key, datetime)` check as datetime is `date` subclass.
elif isinstance(key, date):
dt = key

# Key is `float` or `int` instance.
elif isinstance(key, (float, int)):
dt = datetime.fromtimestamp(key, timezone.utc).date()

# Key is not supported.
else:
raise TypeError(f"Cannot convert type '{type(key)}' to date.")

# Automatically expand for `expand=True` cases.
if self.expand and dt.year not in self.years:
dt = _convert_to_date(key)
# Automatically expand for `expand=True` cases unless since or until prohibits that.
if (
self.expand
and dt.year not in self.years
and (self.since_date is None or dt.year >= self.since_date.year)
and (self.until_date is None or dt.year <= self.until_date.year)
):
self.years.add(dt.year)
self._populate(dt.year)

Expand Down Expand Up @@ -742,7 +725,12 @@ def _add_holiday(self, name: str, *args) -> Optional[date]:
dt = args if len(args) > 1 else args[0]
dt = dt if isinstance(dt, date) else date(self._year, *dt)

if dt.year != self._year:
# Skip dates that don't match current year and since/until range values.
if (
dt.year != self._year
or (self.since_date and dt < self.since_date)
or (self.until_date and dt > self.until_date)
):
return None

self[dt] = self.tr(name)
Expand Down Expand Up @@ -778,6 +766,8 @@ def _check_weekday(self, weekday: int, *args) -> bool:
Returns False otherwise.
"""
dt = args if len(args) > 1 else args[0]
if dt is None:
return False
dt = dt if isinstance(dt, date) else date(self._year, *dt)
return dt.weekday() == weekday

Expand Down
8 changes: 6 additions & 2 deletions holidays/observed_holiday_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
from typing import Dict, Optional, Tuple, Set

from holidays.calendars.gregorian import MON, TUE, WED, THU, FRI, SAT, SUN, _timedelta
from holidays.holiday_base import DateArg, HolidayBase
from holidays.holiday_base import HolidayBase
from holidays.types import DateArg


class ObservedRule(Dict[int, Optional[int]]):
Expand Down Expand Up @@ -142,6 +143,9 @@ def _add_observed(
rule: Optional[ObservedRule] = None,
show_observed_label: bool = True,
) -> Tuple[bool, Optional[date]]:
if dt is None:
return False, None

dt = dt if isinstance(dt, date) else date(self._year, *dt)

if not self.observed or not self._is_observed(dt):
Expand Down Expand Up @@ -199,7 +203,7 @@ def _populate_observed(self, dts: Set[date], multiple: bool = False) -> None:
When multiple is True, each holiday from a given date has its own observed date.
"""
for dt in sorted(dts):
if not self._is_observed(dt):
if dt is None or not self._is_observed(dt):
continue
if multiple:
for name in self.get_list(dt):
Expand Down
Loading

0 comments on commit c7a14cd

Please sign in to comment.