Skip to content

Commit

Permalink
Netherlands school holidays refactor
Browse files Browse the repository at this point in the history
- Small fixes in Netherlands School calendars (#619).
- New method available in `core` module: `Calendar.get_iso_week_date()` to find the weekday X of the week number Y (#619).
- Improve Netherlands coverage (#546, #619).
  • Loading branch information
brunobord committed Jan 15, 2021
1 parent 5253bfd commit 93d621b
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 124 deletions.
4 changes: 3 additions & 1 deletion Changelog.md
Expand Up @@ -2,7 +2,9 @@

## master (unreleased)

Nothing here yet.
- Small fixes in Netherlands School calendars (#619).
- New method available in `core` module: `Calendar.get_iso_week_date()` to find the weekday X of the week number Y (#619).
- Improve Netherlands coverage (#546, #619).

## v14.3.0 (2021-01-15)

Expand Down
42 changes: 42 additions & 0 deletions workalendar/core.py
Expand Up @@ -6,6 +6,7 @@
from calendar import monthrange
from datetime import date, timedelta, datetime
from pathlib import Path
import sys

from calverter import Calverter
from dateutil import easter
Expand All @@ -18,6 +19,7 @@
from . import __version__

MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
ISO_MON, ISO_TUE, ISO_WED, ISO_THU, ISO_FRI, ISO_SAT, ISO_SUN = range(1, 8)


class classproperty:
Expand Down Expand Up @@ -753,6 +755,46 @@ def get_last_weekday_in_month(year, month, weekday):
day = day - timedelta(days=1)
return day

@staticmethod
def get_iso_week_date(year, week_nb, weekday=ISO_MON):
"""
Return the date of the weekday of the week number (ISO definition).
**Warning:** in the ISO definition, the weeks start on MON, not SUN.
By default, if you don't provide the ``weekday`` argument, it'll return
the date of the MON of this week number.
Example:
>>> Calendar.get_iso_week_date(2021, 44)
datetime.date(2021, 11, 1)
For your convenience, the ISO weekdays are available via the
``workalendar.core`` module, like this:
from workalendar.core import ISO_MON, ISO_TUE # etc.
i.e.: if you need to get the FRI of the week 44 of the year 2020,
you'll have to use:
from workalendar.core import ISO_FRI
Calendar.get_iso_week_date(2020, 44, ISO_FRI)
"""
if sys.version_info >= (3, 8, 0):
# use the stock Python 3.8 function
return date.fromisocalendar(year, week_nb, weekday)

# else, use the backport
# Adapted from https://stackoverflow.com/a/59200842
jan_1st = date(year, 1, 1)
_, jan_1st_week, jan_1st_weekday = jan_1st.isocalendar()
base = 1 if jan_1st_week == 1 else 8
delta = base - jan_1st_weekday + 7 * (week_nb - 1) + (weekday - 1)
start = jan_1st + timedelta(days=delta)
return start

@staticmethod
def get_first_weekday_after(day, weekday):
"""Get the first weekday after a given day. If the day is the same
Expand Down
175 changes: 67 additions & 108 deletions workalendar/europe/netherlands.py
@@ -1,5 +1,5 @@
from datetime import date, timedelta
from ..core import WesternCalendar, SUN
from ..core import WesternCalendar, SUN, ISO_SAT
from ..registry_tools import iso_register


Expand Down Expand Up @@ -119,12 +119,7 @@ class NetherlandsWithSchoolHolidays(Netherlands):
https://www.rijksoverheid.nl/onderwerpen/schoolvakanties/overzicht-schoolvakanties-per-schooljaar
"""

def __init__(
self,
region,
carnival_instead_of_spring=False,
**kwargs,
):
def __init__(self, region, carnival_instead_of_spring=False, **kwargs):
""" Set up a calendar incl. school holidays for a specific region
:param region: either "north", "middle" or "south"
Expand All @@ -138,6 +133,14 @@ def __init__(
super().__init__(**kwargs)

def get_fall_holidays(self, year):
"""
Return Fall holidays.
They start at week 43 or 44 and last for 9 days
"""
if year not in FALL_HOLIDAYS_EARLY_REGIONS:
raise NotImplementedError(f"Unknown fall holidays for {year}.")

n_days = 9
week = 43

Expand All @@ -146,31 +149,21 @@ def get_fall_holidays(self, year):
week = 44

# Holiday starts on the preceding Saturday
try:
start = date.fromisocalendar(year, week - 1, 6)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
start = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1) - 2
)
start = self.get_iso_week_date(year, week - 1, ISO_SAT)

# Some regions have their fall holiday 1 week earlier
try:
if self.region in FALL_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)
except KeyError:
raise NotImplementedError(f"Unknown fall holidays for {year}.")
if self.region in FALL_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)

return [
(start + timedelta(days=i), "Fall holiday") for i in range(n_days)
]

def get_christmas_holidays(
self, year, in_december=True, in_january=True
):
""" Christmas holidays run partially in December and partially in January
def get_christmas_holidays(self, year, in_december=True, in_january=True):
"""
Return Christmas holidays
Christmas holidays run partially in December and partially in January
(spillover from previous year).
"""

Expand All @@ -180,20 +173,10 @@ def get_christmas_holidays(
week = date(year, 12, 27).isocalendar()[1]

# Holiday starts on the preceding Saturday
try:
start = date.fromisocalendar(year, week - 1, 6)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
start = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1) - 2
)

start = self.get_iso_week_date(year, week - 1, ISO_SAT)
dates = [
(
start + timedelta(days=i), "Christmas holiday"
) for i in range((date(year, 12, 31) - start).days + 1)
(start + timedelta(days=i), "Christmas holiday")
for i in range((date(year, 12, 31) - start).days + 1)
]

if in_january:
Expand All @@ -202,31 +185,28 @@ def get_christmas_holidays(
)
return dates

n_days = 16
# 27 December is always in a full week of holidays
week = date(year - 1, 12, 27).isocalendar()[1]

# Holiday ends 15 days after the preceding Saturday
try:
end = date.fromisocalendar(
year - 1, week - 1, 6
) + timedelta(days=n_days - 1)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year - 1, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
end = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1)
+ n_days - 3
)
# Saturday of the previous week (previous year!)
start = self.get_iso_week_date(year - 1, week - 1, ISO_SAT)
end = start + timedelta(days=15)

return [
(
date(year, 1, 1) + timedelta(days=i), "Christmas holiday"
) for i in range((end - date(year, 1, 1)).days + 1)
(date(year, 1, 1) + timedelta(days=i), "Christmas holiday")
for i in range((end - date(year, 1, 1)).days + 1)
]

def get_spring_holidays(self, year):
"""
Return the Spring holidays
They start at week 8 or 9 and last for 9 days.
"""
if year not in SPRING_HOLIDAYS_EARLY_REGIONS:
raise NotImplementedError(f"Unknown spring holidays for {year}.")

n_days = 9
week = 9

Expand All @@ -235,43 +215,38 @@ def get_spring_holidays(self, year):
week = 8

# Holiday starts on the preceding Saturday
try:
start = date.fromisocalendar(year, week - 1, 6)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
start = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1) - 2
)
start = self.get_iso_week_date(year, week - 1, ISO_SAT)

# Some regions have their spring holiday 1 week earlier
try:
if self.region in SPRING_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)
except KeyError:
raise NotImplementedError(f"Unknown spring holidays for {year}.")
if self.region in SPRING_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)

return [
(
start + timedelta(days=i), "Spring holiday"
) for i in range(n_days)
(start + timedelta(days=i), "Spring holiday")
for i in range(n_days)
]

def get_carnival_holidays(self, year):
"""Carnival holiday starts 7 weeks and 1 day before Easter Sunday
and lasts 9 days.
"""
Return Carnival holidays
Carnival holidays start 7 weeks and 1 day before Easter Sunday
and last 9 days.
"""
n_days = 9
start = self.get_easter_sunday(year) - timedelta(weeks=7, days=1)

return [
(
start + timedelta(days=i), "Carnival holiday"
) for i in range(n_days)
(start + timedelta(days=i), "Carnival holiday")
for i in range(n_days)
]

def get_may_holidays(self, year):
"""
Return May holidays
They start at week 18 (or 17) and last for 18 days
"""
n_days = 9
week = 18

Expand All @@ -280,53 +255,37 @@ def get_may_holidays(self, year):
week = 17

# Holiday starts on the preceding Saturday
try:
start = date.fromisocalendar(year, week - 1, 6)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
start = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1) - 2
)
start = self.get_iso_week_date(year, week - 1, ISO_SAT)

return [
(start + timedelta(days=i), "May holiday") for i in range(n_days)
]

def get_summer_holidays(self, year):
"""
Return the summer holidays as a list
"""

if year not in SUMMER_HOLIDAYS_EARLY_REGIONS or \
year not in SUMMER_HOLIDAYS_LATE_REGIONS:
raise NotImplementedError(f"Unknown summer holidays for {year}.")

n_days = 44
week = 29

# Holiday starts on the preceding Saturday
try:
start = date.fromisocalendar(year, week - 1, 6)
except AttributeError:
# Adapted from https://stackoverflow.com/a/59200842
first = date(year, 1, 1)
base = 1 if first.isocalendar()[1] == 1 else 8
start = first + timedelta(
days=base - first.isocalendar()[2] + 7 * (week - 1) - 2
)
start = self.get_iso_week_date(year, week - 1, ISO_SAT)

# Some regions have their summer holiday 1 week earlier
try:
if self.region in SUMMER_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)
except KeyError:
raise NotImplementedError(f"Unknown summer holidays for {year}.")
if self.region in SUMMER_HOLIDAYS_EARLY_REGIONS[year]:
start = start - timedelta(weeks=1)

# Some regions have their summer holiday 1 week later
try:
if self.region in SUMMER_HOLIDAYS_LATE_REGIONS[year]:
start = start + timedelta(weeks=1)
except KeyError:
raise NotImplementedError(f"Unknown summer holidays for {year}.")
if self.region in SUMMER_HOLIDAYS_LATE_REGIONS[year]:
start = start + timedelta(weeks=1)

return [
(
start + timedelta(days=i), "Summer holiday"
) for i in range(n_days)
(start + timedelta(days=i), "Summer holiday")
for i in range(n_days)
]

def get_variable_days(self, year):
Expand Down
29 changes: 29 additions & 0 deletions workalendar/tests/test_core.py
Expand Up @@ -8,6 +8,7 @@
from . import CoreCalendarTest
from ..core import (
MON, TUE, THU, FRI, WED, SAT, SUN,
ISO_TUE, ISO_FRI,
Calendar, LunarMixin, WesternCalendar,
CalverterMixin, IslamicMixin, JalaliMixin,
daterange,
Expand Down Expand Up @@ -105,6 +106,34 @@ def test_get_next_weekday_after(self):
date(2015, 4, 14)
)

def test_get_iso_week_date(self):
# Find the MON of the week 1 in 2021
self.assertEqual(
Calendar.get_iso_week_date(2021, 1),
date(2021, 1, 4)
)
# Find the FRI of the week 1 in 2021
self.assertEqual(
Calendar.get_iso_week_date(2021, 1, ISO_FRI),
date(2021, 1, 8)
)

# Find the TUE of the week 44 in 2021
self.assertEqual(
Calendar.get_iso_week_date(2021, 44, ISO_TUE),
date(2021, 11, 2)
)

# Remove this test when dropping support for Python 3.7
@patch('workalendar.core.sys')
def test_get_iso_week_date_patched(self, mock_sys):
# The Python 3.6-3.7 backport should always work
mock_sys.version_info = (3, 6, 0)
self.assertEqual(
Calendar.get_iso_week_date(2021, 44, ISO_TUE),
date(2021, 11, 2)
)


class LunarCalendarTest(TestCase):

Expand Down

0 comments on commit 93d621b

Please sign in to comment.