Repository: vacanza/holidays (python)

Issue:
Incorrect working days calculation in case of substituted holidays from another year
Some countries (e.g. Ukraine, China) have working days moved to the previous or next year (via _substituted holidays_ scheme). If only the year **from which** non-working day is moved is specified, this change is not reflected.

For example, Ukraine has a non-working day moved from December 28, 1996 (Saturday) to January 2, 1997 (Thursday after New Year). Therefore, December 28, 1996 should be a working day. However, it is not:

```python
>>> import holidays
>>> ua = holidays.country_holidays("UA", years=1996)
>>> ua.is_working_day("1996-12-28")
False
```


Pull request description:
Update working days calculation
<!--
  Thanks for contributing to holidays!
-->

## Proposed change

<!--
  Describe the big picture of your changes.
  Don't forget to link your PR to an existing issue if any.
-->

Update working days calculation (if there is substituted holidays from another year) .
Close #2196.

## Type of change

<!--
  Type of change you want to introduce. Please, check one (1) box only!
  If your PR requires multiple boxes to be checked, most likely it needs to
  be split into multiple PRs.
-->

- [ ] New country/market holidays support (thank you!)
- [ ] Supported country/market holidays update (calendar discrepancy fix, localization)
- [x] Existing code/documentation/test/process quality improvement (best practice, cleanup, refactoring, optimization)
- [ ] Dependency update (version deprecation/pin/upgrade)
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] Breaking change (a code change causing existing functionality to break)
- [ ] New feature (new `holidays` functionality in general)

## Checklist

<!--
  Put an `x` in the boxes that apply. You can change them after PR is created.
-->

- [x] I've followed the [contributing guidelines][contributing-guidelines]
- [x] I've successfully run `make check`, all checks and tests are green

<!--
  Thanks again for your contribution!
-->

[contributing-guidelines]: https://github.com/vacanza/holidays/blob/dev/CONTRIBUTING.rst
[docs]: https://github.com/vacanza/holidays/tree/dev/docs/source


Patch:
diff --git a/holidays/groups/custom.py b/holidays/groups/custom.py
index dd54b26d..2120aa1f 100644
--- a/holidays/groups/custom.py
+++ b/holidays/groups/custom.py
@@ -10,6 +10,10 @@
 #  Website: https://github.com/vacanza/holidays
 #  License: MIT (see LICENSE file)
 
+from datetime import date
+
+from holidays.helpers import _normalize_tuple
+
 
 class StaticHolidays:
     """Helper class for special and substituted holidays support.
@@ -33,3 +37,11 @@ class StaticHolidays:
             ):
                 setattr(self, attribute_name, value)
                 self.has_substituted_holidays = True
+
+        # Populate substituted holidays from adjacent years.
+        self.weekend_workdays = set()
+        for special_public_holidays in getattr(self, "special_public_holidays", {}).values():
+            for special_public_holiday in _normalize_tuple(special_public_holidays):
+                if len(special_public_holiday) == 5:  # The fifth element is the year.
+                    _, _, from_month, from_day, from_year = special_public_holiday
+                    self.weekend_workdays.add(date(from_year, from_month, from_day))
diff --git a/holidays/holiday_base.py b/holidays/holiday_base.py
index 052a4d13..2db65448 100644
--- a/holidays/holiday_base.py
+++ b/holidays/holiday_base.py
@@ -233,7 +233,7 @@ class HolidayBase(dict[date, str]):
     ones."""
     weekend: set[int] = {SAT, SUN}
     """Country weekend days."""
-    weekend_workdays: set[date] = set()
+    weekend_workdays: set[date]
     """Working days moved to weekends."""
     default_category: str = PUBLIC
     """The entity category used by default."""
@@ -362,7 +362,7 @@ class HolidayBase(dict[date, str]):
         self.language = language.lower() if language else None
         self.observed = observed
         self.subdiv = subdiv
-        self.weekend_workdays = set()
+        self.weekend_workdays = getattr(self, "weekend_workdays", set())
 
         supported_languages = set(self.supported_languages)
         self.tr = (


Test patch:
diff --git a/tests/countries/test_azerbaijan.py b/tests/countries/test_azerbaijan.py
index 462b1359..69da6762 100644
--- a/tests/countries/test_azerbaijan.py
+++ b/tests/countries/test_azerbaijan.py
@@ -66,6 +66,26 @@ class TestAzerbaijan(CommonCountryTests, TestCase):
             "2025-01-03",
         )
 
+        for year, dts in {
+            2012: (
+                "2012-12-29",
+                "2012-12-30",
+            ),
+            2013: (
+                "2013-12-28",
+                "2013-12-29",
+            ),
+            2019: (
+                "2019-12-28",
+                "2019-12-29",
+            ),
+            2023: ("2023-12-30",),
+            2024: ("2024-12-29",),
+        }.items():
+            az_holidays = Azerbaijan(years=year)
+            for dt in dts:
+                self.assertTrue(az_holidays.is_working_day(dt))
+
     def test_new_years_day(self):
         name = "Yeni il bayramı"
         self.assertHolidayName(name, (f"{year}-01-01" for year in range(1990, 2050)))
diff --git a/tests/countries/test_belarus.py b/tests/countries/test_belarus.py
index 3fe4dfe8..efabbcc8 100644
--- a/tests/countries/test_belarus.py
+++ b/tests/countries/test_belarus.py
@@ -225,6 +225,18 @@ class TestBelarus(CommonCountryTests, TestCase):
             "2030-05-07",
         )
 
+        for year, dts in {
+            2006: (
+                "2006-01-21",
+                "2006-05-06",
+                "2006-11-04",
+                "2006-12-30",
+            ),
+        }.items():
+            be_holidays = Belarus(years=year)
+            for dt in dts:
+                self.assertTrue(be_holidays.is_working_day(dt))
+
     def test_dzyady(self):
         name = "Дзень памяці"
 
diff --git a/tests/countries/test_china.py b/tests/countries/test_china.py
index 35f2ee4e..d24ba147 100644
--- a/tests/countries/test_china.py
+++ b/tests/countries/test_china.py
@@ -29,6 +29,50 @@ class TestChina(CommonCountryTests, TestCase):
         self.assertNoHolidays(China(years=1949))
         self.assertNoHolidays(China(years=1949, categories=HALF_DAY))
 
+    def test_substituted_holidays(self):
+        for year, dts in {
+            2001: (
+                "2001-01-20",
+                "2001-01-21",
+                "2001-04-28",
+                "2001-04-29",
+                "2001-09-29",
+                "2001-09-30",
+                "2001-12-29",
+                "2001-12-30",
+            ),
+            2005: (
+                "2005-02-05",
+                "2005-02-06",
+                "2005-04-30",
+                "2005-05-08",
+                "2005-10-08",
+                "2005-10-09",
+                "2005-12-31",
+            ),
+            2006: (
+                "2006-01-28",
+                "2006-02-05",
+                "2006-04-29",
+                "2006-04-30",
+                "2006-09-30",
+                "2006-10-08",
+                "2006-12-30",
+                "2006-12-31",
+            ),
+            2011: (
+                "2011-01-30",
+                "2011-02-12",
+                "2011-04-02",
+                "2011-10-08",
+                "2011-10-09",
+                "2011-12-31",
+            ),
+        }.items():
+            cn_holidays = China(years=year)
+            for dt in dts:
+                self.assertTrue(cn_holidays.is_working_day(dt))
+
     def test_new_years_day(self):
         self.assertHolidayName("元旦", (f"{year}-01-01" for year in range(1950, 2050)))
 
diff --git a/tests/countries/test_kazakhstan.py b/tests/countries/test_kazakhstan.py
index 1567cc1a..4b54ab1b 100644
--- a/tests/countries/test_kazakhstan.py
+++ b/tests/countries/test_kazakhstan.py
@@ -242,6 +242,17 @@ class TestKazakhstan(CommonCountryTests, TestCase):
             "2025-01-03",
         )
 
+        for year, dts in {
+            2013: (
+                "2013-05-04",
+                "2013-10-12",
+                "2013-12-28",
+            ),
+        }.items():
+            kz_holidays = Kazakhstan(years=year)
+            for dt in dts:
+                self.assertTrue(kz_holidays.is_working_day(dt))
+
     def test2022(self):
         self.assertHolidays(
             Kazakhstan(years=2022),
diff --git a/tests/countries/test_taiwan.py b/tests/countries/test_taiwan.py
index 515741d0..72ba7709 100644
--- a/tests/countries/test_taiwan.py
+++ b/tests/countries/test_taiwan.py
@@ -28,6 +28,14 @@ class TestTaiwan(CommonCountryTests, TestCase):
     def test_no_holidays(self):
         self.assertNoHolidays(Taiwan(years=1911))
 
+    def test_substituted_holidays(self):
+        for year, dts in {
+            2014: ("2014-12-27",),
+        }.items():
+            tw_holidays = Taiwan(years=year)
+            for dt in dts:
+                self.assertTrue(tw_holidays.is_working_day(dt))
+
     def test_new_years_day(self):
         name = "中華民國開國紀念日"
         self.assertHolidayName(name, (f"{year}-01-01" for year in range(1990, 2030)))
diff --git a/tests/countries/test_ukraine.py b/tests/countries/test_ukraine.py
index 630b31bd..2d64387c 100644
--- a/tests/countries/test_ukraine.py
+++ b/tests/countries/test_ukraine.py
@@ -33,6 +33,18 @@ class TestUkraine(CommonCountryTests, TestCase):
     def test_special_holidays(self):
         self.assertHoliday("1995-01-09")
 
+    def test_substituted_holidays(self):
+        for year, dts in {
+            1996: (
+                "1996-05-05",
+                "1996-05-12",
+                "1996-12-28",
+            ),
+        }.items():
+            ua_holidays = Ukraine(years=year)
+            for dt in dts:
+                self.assertTrue(ua_holidays.is_working_day(dt))
+
     def test_new_year_day(self):
         self.assertHoliday(f"{year}-01-01" for year in range(1991, 2023))
         dt = (
diff --git a/tests/countries/test_vietnam.py b/tests/countries/test_vietnam.py
index 38e74235..00c420fc 100644
--- a/tests/countries/test_vietnam.py
+++ b/tests/countries/test_vietnam.py
@@ -174,6 +174,18 @@ class TestVietnam(CommonCountryTests, TestCase):
             "2024-04-29",
         )
 
+        for year, dts in {
+            2014: (
+                "2014-04-26",
+                "2014-09-06",
+                "2014-12-27",
+            ),
+            2019: ("2019-01-05",),
+        }.items():
+            vn_holidays = Vietnam(years=year)
+            for dt in dts:
+                self.assertTrue(vn_holidays.is_working_day(dt))
+
     def test_l10n_default(self):
         self.assertLocalizedHolidays(
             ("2022-01-01", "Tết Dương lịch"),