Repository: peopledoc/workalendar
Pull request #620 (2021-01-15 16:00:13)

Netherlands school holidays refactor
## Changes

- 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).

## Tasks

- [x] Tests with a significant number of years to be tested for your calendar.
- [x] Changelog amended with a mention describing your changes. **Note** *Please do NOT change the version number here. It's the project maintainers' duty.*



Patch:
diff --git a/Changelog.md b/Changelog.md
index 9319f8c..e4b4ce8 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -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)
 
diff --git a/workalendar/core.py b/workalendar/core.py
index 13d6ca3..2dd6d4e 100644
--- a/workalendar/core.py
+++ b/workalendar/core.py
@@ -6,6 +6,7 @@ import warnings
 from calendar import monthrange
 from datetime import date, timedelta, datetime
 from pathlib import Path
+import sys
 
 from calverter import Calverter
 from dateutil import easter
@@ -18,6 +19,7 @@ from .exceptions import (
 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:
@@ -753,6 +755,46 @@ class CoreCalendar:
             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
diff --git a/workalendar/europe/netherlands.py b/workalendar/europe/netherlands.py
index 4c1fa76..7391950 100644
--- a/workalendar/europe/netherlands.py
+++ b/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
 
 
@@ -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"
@@ -138,6 +133,14 @@ class NetherlandsWithSchoolHolidays(Netherlands):
         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
 
@@ -146,31 +149,21 @@ class NetherlandsWithSchoolHolidays(Netherlands):
             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).
         """
 
@@ -180,20 +173,10 @@ class NetherlandsWithSchoolHolidays(Netherlands):
             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:
@@ -202,31 +185,28 @@ class NetherlandsWithSchoolHolidays(Netherlands):
                 )
             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
 
@@ -235,43 +215,38 @@ class NetherlandsWithSchoolHolidays(Netherlands):
             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
 
@@ -280,53 +255,37 @@ class NetherlandsWithSchoolHolidays(Netherlands):
             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):


Test patch:
diff --git a/workalendar/tests/test_core.py b/workalendar/tests/test_core.py
index 37d065f..18c5d5c 100644
--- a/workalendar/tests/test_core.py
+++ b/workalendar/tests/test_core.py
@@ -8,6 +8,7 @@ import pandas
 from . import CoreCalendarTest
 from ..core import (
     MON, TUE, THU, FRI, WED, SAT, SUN,
+    ISO_TUE, ISO_FRI,
     Calendar, LunarMixin, WesternCalendar,
     CalverterMixin, IslamicMixin, JalaliMixin,
     daterange,
@@ -105,6 +106,34 @@ class CalendarTest(CoreCalendarTest):
             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):
 
diff --git a/workalendar/tests/test_europe.py b/workalendar/tests/test_europe.py
index 7c01732..f3f14a4 100644
--- a/workalendar/tests/test_europe.py
+++ b/workalendar/tests/test_europe.py
@@ -1,8 +1,9 @@
 from datetime import date, timedelta, datetime
 from collections import Counter
+from unittest import TestCase
 
 from . import GenericCalendarTest
-from ..core import daterange
+from ..core import daterange, ISO_SAT
 from ..europe import (
     Austria,
     Bulgaria,
@@ -44,6 +45,14 @@ from ..europe import (
 )
 
 
+# Tests dedicated to Netherlands School holidays
+from ..europe.netherlands import (
+    SPRING_HOLIDAYS_EARLY_REGIONS,
+    SUMMER_HOLIDAYS_EARLY_REGIONS,
+    SUMMER_HOLIDAYS_LATE_REGIONS,
+)
+
+
 class AustriaTest(GenericCalendarTest):
     cal_class = Austria
 
@@ -994,27 +1003,42 @@ class NetherlandsTest(GenericCalendarTest):
         self.assertNotIn(date(2016, 12, 31), holidays)
 
 
-class NetherlandsWithSchoolHolidaysWithInvalidRegionTest(GenericCalendarTest):
+class NetherlandsWithSchoolHolidaysWithInvalidRegionTest(TestCase):
 
-    cal_class = NetherlandsWithSchoolHolidays
-    kwargs = dict(region="east")
+    def test_instanciate_invalid_region(self):
+        with self.assertRaises(ValueError) as cm:
+            NetherlandsWithSchoolHolidays(region="east")
+        assert "Set region" in str(cm.exception)
 
-    def setUp(self):
         with self.assertRaises(ValueError) as cm:
-            super().setUp()
+            NetherlandsWithSchoolHolidays(region="")
         assert "Set region" in str(cm.exception)
 
-    def test_weekend_days(self):
-        """Skip test as calendar setup is expected to fail"""
-        pass
+    def test_instanciate_no_region(self):
+        with self.assertRaises(TypeError) as cm:
+            NetherlandsWithSchoolHolidays()
+        assert "missing 1 required positional argument" in str(cm.exception)
 
-    def test_january_1st(self):
-        """Skip test as calendar setup is expected to fail"""
-        pass
 
-    def test_ical_export(self):
-        """Skip test as calendar setup is expected to fail"""
-        pass
+class NetherlandsWithSchoolHolidaysInvalidRanges(TestCase):
+
+    def test_spring_holidays_invalid_range(self):
+        max_year = max(SPRING_HOLIDAYS_EARLY_REGIONS)
+        calendar = NetherlandsWithSchoolHolidays(region="north")
+        with self.assertRaises(NotImplementedError):
+            calendar.get_spring_holidays(max_year + 1)
+
+    def test_summer_early_holidays_invalid_range(self):
+        max_year = max(SUMMER_HOLIDAYS_EARLY_REGIONS)
+        calendar = NetherlandsWithSchoolHolidays(region="north")
+        with self.assertRaises(NotImplementedError):
+            calendar.get_summer_holidays(max_year + 1)
+
+    def test_summer_late_holidays_invalid_range(self):
+        max_year = max(SUMMER_HOLIDAYS_LATE_REGIONS)
+        calendar = NetherlandsWithSchoolHolidays(region="north")
+        with self.assertRaises(NotImplementedError):
+            calendar.get_summer_holidays(max_year + 1)
 
 
 class NetherlandsNorthWithSchoolHolidaysTest(GenericCalendarTest):
@@ -1109,6 +1133,49 @@ class NetherlandsNorthWithSchoolHolidaysTest(GenericCalendarTest):
                 holidays[christmas_holiday_start + timedelta(days=d)],
             )
 
+    def test_may_holidays_exceptions(self):
+        # In 2017, May holidays started on week 17 instead of 18
+
+        # First, make sure we have a coherent date for 2021
+        may_holidays_2021 = self.cal.get_may_holidays(2021)
+        start_2021 = may_holidays_2021[0][0]
+        self.assertEqual(
+            start_2021,
+            # Preceding SAT
+            self.cal.get_iso_week_date(2021, 18 - 1, ISO_SAT)
+        )
+
+        # Then, get the start date for 2017
+        may_holidays_2017 = self.cal.get_may_holidays(2017)
+        start_2017 = may_holidays_2017[0][0]
+        self.assertEqual(
+            start_2017,
+            # Preceding SAT
+            self.cal.get_iso_week_date(2017, 17 - 1, ISO_SAT)
+        )
+
+    def test_fall_holidays_exceptions(self):
+        # In 2024, May holidays start on week 44 instead of 43
+
+        # First, make sure we have a coherent date for 2019
+        # (using 2019 to avoid being in FALL_HOLIDAYS_EARLY_REGIONS)
+        fall_holidays_2019 = self.cal.get_fall_holidays(2019)
+        start_2019 = fall_holidays_2019[0][0]
+        self.assertEqual(
+            start_2019,
+            # Preceding SAT
+            self.cal.get_iso_week_date(2019, 43 - 1, ISO_SAT)
+        )
+
+        # Then, get the start date for 2024
+        fall_holidays_2024 = self.cal.get_fall_holidays(2024)
+        start_2024 = fall_holidays_2024[0][0]
+        self.assertEqual(
+            start_2024,
+            # Preceding SAT
+            self.cal.get_iso_week_date(2024, 44 - 1, ISO_SAT)
+        )
+
 
 class NetherlandsMiddleWithSchoolHolidaysTest(GenericCalendarTest):