Repository: peopledoc/workalendar
Pull request #569 (2020-10-16 13:21:43)

Added School holidays for Netherlands
superseeds, and closes #556


- [x] Tests with a significant number of years to be tested for your calendar.
- [ ] Docstrings for the Calendar class and specific methods.
- [x] N/A ~~Use the ``workalendar.registry_tools.iso_register`` decorator to register your new calendar using ISO codes (optional).~~
- [x] Calendar country / label added to the README.rst file.
- [x] Changelog amended with a mention like: "Added ``<country>`` by ``@pseudo`` (#)". **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 8a27ca0..8f5312e 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -2,7 +2,7 @@
 
 ## master (unreleased)
 
-Nothing here yet.
+- Add optional school holidays to Netherlands calendar, by @Flix6x (#556).
 
 ## v12.1.0 (2020-10-16)
 
diff --git a/README.rst b/README.rst
index c7af59d..ac61dec 100644
--- a/README.rst
+++ b/README.rst
@@ -104,7 +104,7 @@ Europe
 * Luxembourg
 * Malta
 * Monaco
-* Netherlands
+* Netherlands (optionally with school holidays)
 * Norway
 * Poland
 * Portugal
diff --git a/workalendar/core.py b/workalendar/core.py
index af19467..87348c7 100644
--- a/workalendar/core.py
+++ b/workalendar/core.py
@@ -927,6 +927,9 @@ class Calendar(CoreCalendar):
     include_labour_day = False
     labour_day_label = "Labour Day"
 
+    def __init__(self, **kwargs):
+        super().__init__()
+
     def get_fixed_holidays(self, year):
         days = super().get_fixed_holidays(year)
         if self.include_new_years_day:
diff --git a/workalendar/europe/__init__.py b/workalendar/europe/__init__.py
index de466ab..c296a8a 100644
--- a/workalendar/europe/__init__.py
+++ b/workalendar/europe/__init__.py
@@ -21,7 +21,7 @@ from .lithuania import Lithuania
 from .luxembourg import Luxembourg
 from .malta import Malta
 from .monaco import Monaco
-from .netherlands import Netherlands
+from .netherlands import Netherlands, NetherlandsWithSchoolHolidays
 from .norway import Norway
 from .poland import Poland
 from .portugal import Portugal
@@ -92,6 +92,7 @@ __all__ = (
     'Malta',
     'Monaco',
     'Netherlands',
+    'NetherlandsWithSchoolHolidays',
     'Norway',
     'Poland',
     'Portugal',
diff --git a/workalendar/europe/netherlands.py b/workalendar/europe/netherlands.py
index fba5de1..db65782 100644
--- a/workalendar/europe/netherlands.py
+++ b/workalendar/europe/netherlands.py
@@ -40,3 +40,262 @@ class Netherlands(WesternCalendar):
         days = super().get_variable_days(year)
         days.append(self.get_king_queen_day(year))
         return days
+
+
+FALL_HOLIDAYS_EARLY_REGIONS = {
+    2025: ["south"],
+    2024: ["south"],
+    2023: ["middle", "south"],
+    2022: ["north"],
+    2021: ["north", "middle"],
+    2020: ["north"],
+    2019: ["south"],
+    2018: ["south"],
+    2017: ["middle", "south"],
+    2016: ["north", "middle"],
+}
+
+SPRING_HOLIDAYS_EARLY_REGIONS = {
+    2025: ["north"],
+    2024: ["south"],
+    2023: ["south"],
+    2022: ["north"],
+    2021: ["south"],
+    2020: ["north"],
+    2019: ["north"],
+    2018: ["south"],
+    2017: ["north"],
+    2016: ["middle", "south"],
+}
+
+SUMMER_HOLIDAYS_EARLY_REGIONS = {
+    2025: ["south"],
+    2024: ["south"],
+    2023: ["middle"],
+    2022: ["middle"],
+    2021: ["north"],
+    2020: ["north"],
+    2019: ["south"],
+    2018: ["south"],
+    2017: ["middle"],
+    2016: ["middle"],
+}
+
+SUMMER_HOLIDAYS_LATE_REGIONS = {
+    2025: ["middle"],
+    2024: ["north"],
+    2023: ["north"],
+    2022: ["south"],
+    2021: ["south"],
+    2020: ["middle"],
+    2019: ["middle"],
+    2018: ["north"],
+    2017: ["north"],
+    2016: ["south"],
+}
+
+
+class NetherlandsWithSchoolHolidays(Netherlands):
+    """Netherlands with school holidays (2016 to 2025).
+
+    Data source and regulating body:
+    https://www.rijksoverheid.nl/onderwerpen/schoolvakanties/overzicht-schoolvakanties-per-schooljaar
+    """
+
+    def __init__(self, region):
+        """ Set up a calendar incl. school holidays for a specific region
+
+        :param region: either "north", "middle" or "south"
+        """
+        if region not in ("north", "middle", "south"):
+            raise ValueError("Set region to 'north', 'middle' or 'south'.")
+        self.region = region
+        super().__init__()
+
+    def get_fall_holidays(self, year):
+        n_days = 9
+        week = 43
+
+        # Exceptional years
+        if year == 2024:
+            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
+            )
+
+        # 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("Unknown fall holidays for %d." % year)
+
+        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
+        (spillover from previous year).
+        """
+
+        if in_december:
+
+            # 27 December is always in a full week of 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
+                )
+
+            dates = [
+                (
+                    start + timedelta(days=i), "Christmas holiday"
+                ) for i in range((date(year, 12, 31) - start).days + 1)
+            ]
+
+            if in_january:
+                dates.extend(
+                    self.get_christmas_holidays(year, in_december=False)
+                )
+            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
+            )
+
+        return [
+            (
+                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):
+        n_days = 9
+        week = 9
+
+        # Exceptional years
+        if year in [2024, 2021]:
+            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
+            )
+
+        # 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("Unknown spring holidays for %d." % year)
+
+        return [
+            (
+                start + timedelta(days=i), "Spring holiday"
+            ) for i in range(n_days)
+        ]
+
+    def get_may_holidays(self, year):
+        n_days = 9
+        week = 18
+
+        # Exceptional years
+        if year == 2017:
+            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
+            )
+
+        return [
+            (start + timedelta(days=i), "May holiday") for i in range(n_days)
+        ]
+
+    def get_summer_holidays(self, 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
+            )
+
+        # 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("Unknown summer holidays for %d." % year)
+
+        # 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("Unknown summer holidays for %d." % year)
+
+        return [
+            (
+                start + timedelta(days=i), "Summer holiday"
+            ) for i in range(n_days)
+        ]
+
+    def get_variable_days(self, year):
+        days = super().get_variable_days(year)
+        days.extend(self.get_fall_holidays(year))
+        days.extend(self.get_christmas_holidays(year))
+        days.extend(self.get_spring_holidays(year))
+        days.extend(self.get_may_holidays(year))
+        days.extend(self.get_summer_holidays(year))
+        return days


Test patch:
diff --git a/workalendar/tests/__init__.py b/workalendar/tests/__init__.py
index db9151c..3d95165 100644
--- a/workalendar/tests/__init__.py
+++ b/workalendar/tests/__init__.py
@@ -13,11 +13,12 @@ from .. import __version__
 
 class CoreCalendarTest(TestCase):
     cal_class = Calendar
+    kwargs = {}
 
     def setUp(self):
         super().setUp()
         self.year = date.today().year
-        self.cal = self.cal_class()
+        self.cal = self.cal_class(**self.kwargs)
 
 
 class GenericCalendarTest(CoreCalendarTest):
diff --git a/workalendar/tests/test_europe.py b/workalendar/tests/test_europe.py
index 6ca0cf8..1c196b1 100644
--- a/workalendar/tests/test_europe.py
+++ b/workalendar/tests/test_europe.py
@@ -1,4 +1,4 @@
-from datetime import date
+from datetime import date, timedelta
 from collections import Counter
 
 from . import GenericCalendarTest
@@ -28,6 +28,7 @@ from ..europe import (
     Malta,
     Monaco,
     Netherlands,
+    NetherlandsWithSchoolHolidays,
     Norway,
     Poland,
     Portugal,
@@ -909,7 +910,7 @@ class NetherlandsTest(GenericCalendarTest):
 
     cal_class = Netherlands
 
-    def test_year_2016(self):
+    def test_year_2015(self):
         holidays = self.cal.holidays_set(2015)
         self.assertIn(date(2015, 1, 1), holidays)   # New Year
         self.assertIn(date(2015, 4, 3), holidays)   # Good friday
@@ -987,6 +988,308 @@ class NetherlandsTest(GenericCalendarTest):
         self.assertNotIn(date(2016, 12, 31), holidays)
 
 
+class NetherlandsWithSchoolHolidaysWithInvalidRegionTest(GenericCalendarTest):
+
+    cal_class = NetherlandsWithSchoolHolidays
+    kwargs = dict(region="east")
+
+    def setUp(self):
+        with self.assertRaises(ValueError) as cm:
+            super().setUp()
+        assert "Set region" in str(cm.exception)
+
+    def test_weekend_days(self):
+        """Skip test as calendar setup is expected to fail"""
+        pass
+
+    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 NetherlandsNorthWithSchoolHolidaysTest(GenericCalendarTest):
+
+    cal_class = NetherlandsWithSchoolHolidays
+    kwargs = dict(region="north")
+
+    def test_year_2010(self):
+        with self.assertRaises(NotImplementedError):
+            dict(self.cal.holidays(2010))
+
+    def test_year_2020(self):
+        year = 2020
+        christmas_holiday_end = date(2020, 1, 5)
+        spring_holiday_start = date(2020, 2, 15)
+        may_holiday_start = date(2020, 4, 25)
+        summer_holiday_start = date(2020, 7, 4)
+        fall_holiday_start = date(2020, 10, 10)
+        christmas_holiday_start = date(2020, 12, 19)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def test_year_2021(self):
+        year = 2021
+        christmas_holiday_end = date(2021, 1, 3)
+        spring_holiday_start = date(2021, 2, 20)
+        may_holiday_start = date(2021, 5, 1)
+        summer_holiday_start = date(2021, 7, 10)
+        fall_holiday_start = date(2021, 10, 16)
+        christmas_holiday_start = date(2021, 12, 25)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def _test_school_holidays(
+            self,
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+    ):
+        holidays = {}
+        for h in self.cal.holidays(year):
+            holidays.setdefault(h[0], []).append(h[1])
+
+        for d in range(christmas_holiday_end.day):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_end - timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Spring holiday",
+                holidays[spring_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "May holiday",
+                holidays[may_holiday_start + timedelta(days=d)],
+            )
+        for d in range(44):
+            self.assertIn(
+                "Summer holiday",
+                holidays[summer_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Fall holiday",
+                holidays[fall_holiday_start + timedelta(days=d)],
+            )
+        for d in range(31 - christmas_holiday_start.day + 1):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_start + timedelta(days=d)],
+            )
+
+
+class NetherlandsMiddleWithSchoolHolidaysTest(GenericCalendarTest):
+
+    cal_class = NetherlandsWithSchoolHolidays
+    kwargs = dict(region="middle")
+
+    def test_year_2010(self):
+        with self.assertRaises(NotImplementedError):
+            dict(self.cal.holidays(2010))
+
+    def test_year_2020(self):
+        year = 2020
+        christmas_holiday_end = date(2020, 1, 5)
+        spring_holiday_start = date(2020, 2, 22)
+        may_holiday_start = date(2020, 4, 25)
+        summer_holiday_start = date(2020, 7, 18)
+        fall_holiday_start = date(2020, 10, 17)
+        christmas_holiday_start = date(2020, 12, 19)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def test_year_2021(self):
+        year = 2021
+        christmas_holiday_end = date(2021, 1, 3)
+        spring_holiday_start = date(2021, 2, 20)
+        may_holiday_start = date(2021, 5, 1)
+        summer_holiday_start = date(2021, 7, 17)
+        fall_holiday_start = date(2021, 10, 16)
+        christmas_holiday_start = date(2021, 12, 25)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def _test_school_holidays(
+            self,
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+    ):
+        holidays = {}
+        for h in self.cal.holidays(year):
+            holidays.setdefault(h[0], []).append(h[1])
+
+        for d in range(christmas_holiday_end.day):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_end - timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Spring holiday",
+                holidays[spring_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "May holiday",
+                holidays[may_holiday_start + timedelta(days=d)],
+            )
+        for d in range(44):
+            self.assertIn(
+                "Summer holiday",
+                holidays[summer_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Fall holiday",
+                holidays[fall_holiday_start + timedelta(days=d)],
+            )
+        for d in range(31 - christmas_holiday_start.day + 1):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_start + timedelta(days=d)],
+            )
+
+
+class NetherlandsSouthWithSchoolHolidaysTest(GenericCalendarTest):
+
+    cal_class = NetherlandsWithSchoolHolidays
+    kwargs = dict(region="south")
+
+    def test_year_2010(self):
+        with self.assertRaises(NotImplementedError):
+            dict(self.cal.holidays(2010))
+
+    def test_year_2020(self):
+        year = 2020
+        christmas_holiday_end = date(2020, 1, 5)
+        spring_holiday_start = date(2020, 2, 22)
+        may_holiday_start = date(2020, 4, 25)
+        summer_holiday_start = date(2020, 7, 11)
+        fall_holiday_start = date(2020, 10, 17)
+        christmas_holiday_start = date(2020, 12, 19)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def test_year_2021(self):
+        year = 2021
+        christmas_holiday_end = date(2021, 1, 3)
+        spring_holiday_start = date(2021, 2, 13)
+        may_holiday_start = date(2021, 5, 1)
+        summer_holiday_start = date(2021, 7, 24)
+        fall_holiday_start = date(2021, 10, 23)
+        christmas_holiday_start = date(2021, 12, 25)
+
+        self._test_school_holidays(
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+        )
+
+    def _test_school_holidays(
+            self,
+            year,
+            christmas_holiday_start,
+            spring_holiday_start,
+            may_holiday_start,
+            summer_holiday_start,
+            fall_holiday_start,
+            christmas_holiday_end,
+    ):
+        holidays = {}
+        for h in self.cal.holidays(year):
+            holidays.setdefault(h[0], []).append(h[1])
+
+        for d in range(christmas_holiday_end.day):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_end - timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Spring holiday",
+                holidays[spring_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "May holiday",
+                holidays[may_holiday_start + timedelta(days=d)],
+            )
+        for d in range(44):
+            self.assertIn(
+                "Summer holiday",
+                holidays[summer_holiday_start + timedelta(days=d)],
+            )
+        for d in range(9):
+            self.assertIn(
+                "Fall holiday",
+                holidays[fall_holiday_start + timedelta(days=d)],
+            )
+        for d in range(31 - christmas_holiday_start.day + 1):
+            self.assertIn(
+                "Christmas holiday",
+                holidays[christmas_holiday_start + timedelta(days=d)],
+            )
+
+
 class Romania(GenericCalendarTest):
     cal_class = Romania