This page was opened through a temporary download link, which now only works for a few minutes and is removed from the address bar. To share the walkthrough, share the pull request or its link on GitHub. Reloading will not work; open it again from the pull request.
Walkthrough for: Add Easter-based holidays, Midsummer and All Saints' Day (walkthrough format v3) (PR #4)
Business-day counts are now right around Easter, Ascension Day, Midsummer and All Saints' Day; before this, Good Friday was a working day. All the new rules feed holidays(), so the calendar functions pick them up unchanged. The Easter calculation is the part to check, and it matches an independent implementation for every year it can be checked against.
Sources 5 cited
- Date of Easter: Anonymous Gregorian algorithm docs โ The same steps and constants, including the
+ 1on the day. - dateutil.easter prior-art โ Independent implementation; its Western method is documented as valid for 1583 to 4099.
- Public holidays in Finland docs โ Lists the same rules ("39 days after Easter Sunday", "49 days after Easter Sunday").
- Date of Easter docs โ "falls on a date in the range 22 March to 25 April inclusive"
date.weekday()docs โ "Monday is 0 and Sunday is 6"
1. Business days already come from holidays()
context
This is unchanged, and it's why the change is so small. Both calendar functions go through is_business_day(), and it looks the date up in holidays(year) through a per-year cache. So a holiday added to holidays() is a day off everywhere, and calendar.py doesn't change. The cache is safe for the same reason as before: holidays() depends on nothing but the year.
holidays() takes three kinds of rule
holidays() used to be the fixed dates and nothing else. Now it adds the fixed dates, the Easter offsets and the weekday rules through one add(), and returns them in date order. The one behaviour change for callers is how it handles two holidays on the same date.
2. Every rule goes through the same add(), sorted at the end
core
The fixed dates, then Easter computed once with each offset from the table, then the three weekday rules. The fixed holidays now go through add() like the others, and the result is sorted so it reads like a calendar; the 2026 test compares the whole dict in date order. The Easter and weekday pieces each get their own group below.
| 52 | + When two holidays fall on the same date, the names are joined. | |
| 21 | 53 | """ |
| 22 | - return {date(year, month, day): name for (month, day), name in FIXED_HOLIDAYS.items()} | |
| 54 | + days: dict[date, str] = {} | |
| 55 | + | |
| 56 | + def add(day: date, name: str) -> None: | |
| 57 | + days[day] = f"{days[day]}, {name}" if day in days else name | |
| 58 | + | |
| 59 | + for (month, day), name in FIXED_HOLIDAYS.items(): | |
| 60 | + add(date(year, month, day), name) | |
| 61 | + easter = easter_sunday(year) | |
| 62 | + for offset, name in EASTER_HOLIDAYS.items(): | |
| 63 | + add(easter + timedelta(days=offset), name) | |
| 64 | + # Midsummer Eve, like Christmas Eve, is a day off in practice rather than by statute. | |
| 65 | + add(_weekday_in_week(year, 6, 19, FRIDAY), "Midsummer Eve") | |
| 66 | + add(_weekday_in_week(year, 6, 20, SATURDAY), "Midsummer Day") | |
| 67 | + add(_weekday_in_week(year, 10, 31, SATURDAY), "All Saints' Day") | |
| 68 | + return dict(sorted(days.items())) |
| 52 | + When two holidays fall on the same date, the names are joined. | ||
| 21 | """ | 53 | """ |
| 22 | - return {date(year, month, day): name for (month, day), name in FIXED_HOLIDAYS.items()} | 54 | + days: dict[date, str] = {} |
| 55 | + | ||
| 56 | + def add(day: date, name: str) -> None: | ||
| 57 | + days[day] = f"{days[day]}, {name}" if day in days else name | ||
| 58 | + | ||
| 59 | + for (month, day), name in FIXED_HOLIDAYS.items(): | ||
| 60 | + add(date(year, month, day), name) | ||
| 61 | + easter = easter_sunday(year) | ||
| 62 | + for offset, name in EASTER_HOLIDAYS.items(): | ||
| 63 | + add(easter + timedelta(days=offset), name) | ||
| 64 | + # Midsummer Eve, like Christmas Eve, is a day off in practice rather than by statute. | ||
| 65 | + add(_weekday_in_week(year, 6, 19, FRIDAY), "Midsummer Eve") | ||
| 66 | + add(_weekday_in_week(year, 6, 20, SATURDAY), "Midsummer Day") | ||
| 67 | + add(_weekday_in_week(year, 10, 31, SATURDAY), "All Saints' Day") | ||
| 68 | + return dict(sorted(days.items())) |
3. Keep both names when two holidays share a date core
A moving holiday can land on a fixed one (Ascension Day was on May Day in 2008). Overwriting would silently drop a name, so add() joins them, and the docstring says so.
- risk: Callers matching on an exact name see
"May Day, Ascension Day"in those years. Nothing in this repository compares names.
| 18 | 50 | """Public holidays in Finland in ``year``, by date. |
| 19 | 51 | |
| 20 | - Only holidays on fixed dates are included so far. | |
| 52 | + When two holidays fall on the same date, the names are joined. | |
| 21 | 53 | """ |
| 22 | - return {date(year, month, day): name for (month, day), name in FIXED_HOLIDAYS.items()} | |
| 54 | + days: dict[date, str] = {} | |
| 55 | + | |
| 56 | + def add(day: date, name: str) -> None: | |
| 57 | + days[day] = f"{days[day]}, {name}" if day in days else name | |
| 58 | + | |
| 59 | + for (month, day), name in FIXED_HOLIDAYS.items(): |
| 18 | """Public holidays in Finland in ``year``, by date. | 50 | """Public holidays in Finland in ``year``, by date. |
| 19 | 51 | ||
| 20 | - Only holidays on fixed dates are included so far. | 52 | + When two holidays fall on the same date, the names are joined. |
| 21 | """ | 53 | """ |
| 22 | - return {date(year, month, day): name for (month, day), name in FIXED_HOLIDAYS.items()} | 54 | + days: dict[date, str] = {} |
| 55 | + | ||
| 56 | + def add(day: date, name: str) -> None: | ||
| 57 | + days[day] = f"{days[day]}, {name}" if day in days else name | ||
| 58 | + | ||
| 59 | + for (month, day), name in FIXED_HOLIDAYS.items(): |
4. Test the full 2026 calendar and the 2008 coincidence test
I wrote the 2026 list out by hand from the calendar instead of computing it, so it checks every rule below against an independent source. len(days) == 7 is gone: it was only ever true for the fixed holidays, and the full list says more.
โ
uv run -q pytest tests/ -q โ exit 0 (13 passed in 0.01s)
............. [100%] 13 passed in 0.01s
| 8 | 11 | assert days[date(2026, 1, 1)] == "New Year's Day" |
| 9 | 12 | assert days[date(2026, 12, 6)] == "Independence Day" |
| 10 | - assert len(days) == 7 | |
| 13 | + | |
| 14 | + | |
| 15 | +@pytest.mark.parametrize( | |
| 16 | + "year, expected", | |
| 17 | + [ | |
| 18 | + (2025, date(2025, 4, 20)), | |
| 19 | + (2026, date(2026, 4, 5)), | |
| 20 | + (2027, date(2027, 3, 28)), | |
| 21 | + (2038, date(2038, 4, 25)), # latest possible date | |
| 22 | + (2285, date(2285, 3, 22)), # earliest possible date | |
| 23 | + ], | |
| 24 | +) | |
| 25 | +def test_easter_sunday(year: int, expected: date) -> None: | |
| 26 | + assert easter_sunday(year) == expected | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_all_holidays_2026() -> None: | |
| 30 | + assert holidays(2026) == { | |
| 31 | + date(2026, 1, 1): "New Year's Day", | |
| 32 | + date(2026, 1, 6): "Epiphany", | |
| 33 | + date(2026, 4, 3): "Good Friday", | |
| 34 | + date(2026, 4, 5): "Easter Sunday", | |
| 35 | + date(2026, 4, 6): "Easter Monday", | |
| 36 | + date(2026, 5, 1): "May Day", | |
| 37 | + date(2026, 5, 14): "Ascension Day", | |
| 38 | + date(2026, 5, 24): "Whitsunday", | |
| 39 | + date(2026, 6, 19): "Midsummer Eve", | |
| 40 | + date(2026, 6, 20): "Midsummer Day", | |
| 41 | + date(2026, 10, 31): "All Saints' Day", | |
| 42 | + date(2026, 12, 6): "Independence Day", | |
| 43 | + date(2026, 12, 24): "Christmas Eve", | |
| 44 | + date(2026, 12, 25): "Christmas Day", | |
| 45 | + date(2026, 12, 26): "St. Stephen's Day", | |
| 46 | + } | |
| 47 | + | |
| 48 | + |
| 8 | assert days[date(2026, 1, 1)] == "New Year's Day" | 11 | assert days[date(2026, 1, 1)] == "New Year's Day" |
| 9 | assert days[date(2026, 12, 6)] == "Independence Day" | 12 | assert days[date(2026, 12, 6)] == "Independence Day" |
| 10 | - assert len(days) == 7 | 13 | + |
| 14 | + | ||
| 15 | +@pytest.mark.parametrize( | ||
| 16 | + "year, expected", | ||
| 17 | + [ | ||
| 18 | + (2025, date(2025, 4, 20)), | ||
| 19 | + (2026, date(2026, 4, 5)), | ||
| 20 | + (2027, date(2027, 3, 28)), | ||
| 21 | + (2038, date(2038, 4, 25)), # latest possible date | ||
| 22 | + (2285, date(2285, 3, 22)), # earliest possible date | ||
| 23 | + ], | ||
| 24 | +) | ||
| 25 | +def test_easter_sunday(year: int, expected: date) -> None: | ||
| 26 | + assert easter_sunday(year) == expected | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def test_all_holidays_2026() -> None: | ||
| 30 | + assert holidays(2026) == { | ||
| 31 | + date(2026, 1, 1): "New Year's Day", | ||
| 32 | + date(2026, 1, 6): "Epiphany", | ||
| 33 | + date(2026, 4, 3): "Good Friday", | ||
| 34 | + date(2026, 4, 5): "Easter Sunday", | ||
| 35 | + date(2026, 4, 6): "Easter Monday", | ||
| 36 | + date(2026, 5, 1): "May Day", | ||
| 37 | + date(2026, 5, 14): "Ascension Day", | ||
| 38 | + date(2026, 5, 24): "Whitsunday", | ||
| 39 | + date(2026, 6, 19): "Midsummer Eve", | ||
| 40 | + date(2026, 6, 20): "Midsummer Day", | ||
| 41 | + date(2026, 10, 31): "All Saints' Day", | ||
| 42 | + date(2026, 12, 6): "Independence Day", | ||
| 43 | + date(2026, 12, 24): "Christmas Eve", | ||
| 44 | + date(2026, 12, 25): "Christmas Day", | ||
| 45 | + date(2026, 12, 26): "St. Stephen's Day", | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + |
| 54 | + | |
| 55 | + | |
| 56 | +def test_coinciding_holidays_keep_both_names() -> None: | |
| 57 | + # In 2008 Ascension Day fell on May Day. | |
| 58 | + assert holidays(2008)[date(2008, 5, 1)] == "May Day, Ascension Day" |
| 54 | + | ||
| 55 | + | ||
| 56 | +def test_coinciding_holidays_keep_both_names() -> None: | ||
| 57 | + # In 2008 Ascension Day fell on May Day. | ||
| 58 | + assert holidays(2008)[date(2008, 5, 1)] == "May Day, Ascension Day" |
Easter and the holidays that hang off it look closely
Five holidays are fixed offsets from Easter Sunday, so everything here depends on computing Easter right. That's the one place in this change where a subtle mistake could survive the tests, so I checked it against an independent implementation for 2517 years.
5. Compute Easter Sunday with the anonymous Gregorian algorithm core
This is the algorithm from the Wikipedia section, step for step, which is why the names are a to m rather than descriptive: you can hold the two side by side. L is upper case only so it doesn't read as 1. It's integer arithmetic throughout, so there's no rounding to worry about. To be sure, I compared it with dateutil.easter.easter() for every year dateutil supports.
- precedent: Same results as
dateutil.easter.easter()in all 2517 years from 1583 to 4099 (the cross-check evidence below). - alternative: Depending on
dateutilinstead would add the library's first dependency for one function. - gap: Years before 1583 return a date without complaint; the Gregorian rule doesn't apply there, and nobody counts business days that far back.
- Date of Easter: Anonymous Gregorian algorithm docs โ The same steps and constants, including the
+ 1on the day. - dateutil.easter prior-art โ Independent implementation; its Western method is documented as valid for 1583 to 4099.
โ
uv run -q --with python-dateutil python -c ' from datetime import date, timedelโฆ โ exit 0 (2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099))
$ uv run -q --with python-dateutil python -c '
from datetime import date, timedelta
from dateutil.easter import easter
from workdays.holidays import easter_sunday, holidays
years = range(1583, 4100)
easter_ok = sum(easter_sunday(y) == easter(y) for y in years)
def rules_hold(y):
h = {name: d for d, names in holidays(y).items() for name in names.split('"'"', '"'"')}
eve, day, saints = h['"'"'Midsummer Eve'"'"'], h['"'"'Midsummer Day'"'"'], h["All Saints'"'"' Day"]
return (eve.weekday() == 4 and date(y, 6, 19) <= eve <= date(y, 6, 25) and day == eve + timedelta(days=1)
and saints.weekday() == 5 and date(y, 10, 31) <= saints <= date(y, 11, 6))
rules_ok = sum(rules_hold(y) for y in years)
print(f'"'"'{easter_ok}/{len(years)} years match dateutil easter(); weekday rules hold in {rules_ok}/{len(years)} (1583-4099)'"'"')
'2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099)
| 26 | + | |
| 27 | + | |
| 28 | +def easter_sunday(year: int) -> date: | |
| 29 | + """Easter Sunday in the Gregorian calendar (the anonymous Gregorian algorithm).""" | |
| 30 | + a = year % 19 | |
| 31 | + b, c = divmod(year, 100) | |
| 32 | + d, e = divmod(b, 4) | |
| 33 | + f = (b + 8) // 25 | |
| 34 | + g = (b - f + 1) // 3 | |
| 35 | + h = (19 * a + b - d - g + 15) % 30 | |
| 36 | + i, k = divmod(c, 4) | |
| 37 | + L = (32 + 2 * e + 2 * i - h - k) % 7 | |
| 38 | + m = (a + 11 * h + 22 * L) // 451 | |
| 39 | + month, day = divmod(h + L - 7 * m + 114, 31) | |
| 40 | + return date(year, month, day + 1) | |
| 41 | + | |
| 42 | + |
| 26 | + | ||
| 27 | + | ||
| 28 | +def easter_sunday(year: int) -> date: | ||
| 29 | + """Easter Sunday in the Gregorian calendar (the anonymous Gregorian algorithm).""" | ||
| 30 | + a = year % 19 | ||
| 31 | + b, c = divmod(year, 100) | ||
| 32 | + d, e = divmod(b, 4) | ||
| 33 | + f = (b + 8) // 25 | ||
| 34 | + g = (b - f + 1) // 3 | ||
| 35 | + h = (19 * a + b - d - g + 15) % 30 | ||
| 36 | + i, k = divmod(c, 4) | ||
| 37 | + L = (32 + 2 * e + 2 * i - h - k) % 7 | ||
| 38 | + m = (a + 11 * h + 22 * L) // 451 | ||
| 39 | + month, day = divmod(h + L - 7 * m + 114, 31) | ||
| 40 | + return date(year, month, day + 1) | ||
| 41 | + | ||
| 42 | + |
6. The five Easter holidays as day offsets core
Good Friday is two days before Easter, Easter Monday one after, Ascension 39 and Whitsunday 49. A table keeps that checkable at a glance next to FIXED_HOLIDAYS. Easter Sunday and Whitsunday never change a business-day count, but holidays() is also the public list of holidays.
- Public holidays in Finland docs โ Lists the same rules ("39 days after Easter Sunday", "49 days after Easter Sunday").
| 14 | 14 | } |
| 15 | 15 | |
| 16 | +# Holidays at a fixed offset in days from Easter Sunday. | |
| 17 | +EASTER_HOLIDAYS: dict[int, str] = { | |
| 18 | + -2: "Good Friday", | |
| 19 | + 0: "Easter Sunday", | |
| 20 | + 1: "Easter Monday", | |
| 21 | + 39: "Ascension Day", | |
| 22 | + 49: "Whitsunday", | |
| 23 | +} | |
| 24 | + | |
| 25 | +FRIDAY, SATURDAY = 4, 5 |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | +# Holidays at a fixed offset in days from Easter Sunday. | ||
| 17 | +EASTER_HOLIDAYS: dict[int, str] = { | ||
| 18 | + -2: "Good Friday", | ||
| 19 | + 0: "Easter Sunday", | ||
| 20 | + 1: "Easter Monday", | ||
| 21 | + 39: "Ascension Day", | ||
| 22 | + 49: "Whitsunday", | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +FRIDAY, SATURDAY = 4, 5 |
7. Test the earliest and latest possible Easter test
Western Easter falls between 22 March and 25 April, and the edges are where an off-by-one in the algorithm would show, so the cases include 2285 (22 March) and 2038 (25 April) along with three ordinary years.
- Date of Easter docs โ "falls on a date in the range 22 March to 25 April inclusive"
โ
uv run -q pytest tests/ -q โ exit 0 (13 passed in 0.01s)
............. [100%] 13 passed in 0.01s
| 13 | + | |
| 14 | + | |
| 15 | +@pytest.mark.parametrize( | |
| 16 | + "year, expected", | |
| 17 | + [ | |
| 18 | + (2025, date(2025, 4, 20)), | |
| 19 | + (2026, date(2026, 4, 5)), | |
| 20 | + (2027, date(2027, 3, 28)), | |
| 21 | + (2038, date(2038, 4, 25)), # latest possible date | |
| 22 | + (2285, date(2285, 3, 22)), # earliest possible date | |
| 23 | + ], | |
| 24 | +) | |
| 25 | +def test_easter_sunday(year: int, expected: date) -> None: | |
| 26 | + assert easter_sunday(year) == expected | |
| 27 | + | |
| 28 | + |
| 13 | + | ||
| 14 | + | ||
| 15 | +@pytest.mark.parametrize( | ||
| 16 | + "year, expected", | ||
| 17 | + [ | ||
| 18 | + (2025, date(2025, 4, 20)), | ||
| 19 | + (2026, date(2026, 4, 5)), | ||
| 20 | + (2027, date(2027, 3, 28)), | ||
| 21 | + (2038, date(2038, 4, 25)), # latest possible date | ||
| 22 | + (2285, date(2285, 3, 22)), # earliest possible date | ||
| 23 | + ], | ||
| 24 | +) | ||
| 25 | +def test_easter_sunday(year: int, expected: date) -> None: | ||
| 26 | + assert easter_sunday(year) == expected | ||
| 27 | + | ||
| 28 | + |
8. Good Friday is no longer a business day test
The point of the change, through the public functions: Easter week 2026 has four business days, and the business day after Thursday 2 April is Tuesday 7 April. The before/after run below shows it used to be Good Friday.
โ
uv run -q pytest tests/ -q โ exit 0 (13 passed in 0.01s)
............. [100%] 13 passed in 0.01s
โ
uv run -q python -c 'from datetime import date; from workdays import add_busineโฆ: before Thu 2 Apr 2026 + 1 business day = 2026-04-03 โ after Thu 2 Apr 2026 + 1 business day = 2026-04-07
$ uv run -q python -c 'from datetime import date; from workdays import add_business_days; print('"'"'Thu 2 Apr 2026 + 1 business day ='"'"', add_business_days(date(2026, 4, 2), 1))'Thu 2 Apr 2026 + 1 business day = 2026-04-03
Thu 2 Apr 2026 + 1 business day = 2026-04-07
| 19 | 19 | |
| 20 | 20 | |
| 21 | +def test_easter_is_not_business_days() -> None: | |
| 22 | + # Mon 30.3. โ Mon 6.4.2026: Good Friday is a holiday, Easter Monday is excluded as the end. | |
| 23 | + assert business_days_between(date(2026, 3, 30), date(2026, 4, 6)) == 4 | |
| 24 | + assert add_business_days(date(2026, 4, 2), 1) == date(2026, 4, 7) | |
| 25 | + | |
| 26 | + | |
| 21 | 27 | def test_add_business_days() -> None: |
| 22 | 28 | assert add_business_days(date(2026, 11, 6), 1) == date(2026, 11, 9) # Fri + 1 = Mon |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | +def test_easter_is_not_business_days() -> None: | ||
| 22 | + # Mon 30.3. โ Mon 6.4.2026: Good Friday is a holiday, Easter Monday is excluded as the end. | ||
| 23 | + assert business_days_between(date(2026, 3, 30), date(2026, 4, 6)) == 4 | ||
| 24 | + assert add_business_days(date(2026, 4, 2), 1) == date(2026, 4, 7) | ||
| 25 | + | ||
| 26 | + | ||
| 21 | def test_add_business_days() -> None: | 27 | def test_add_business_days() -> None: |
| 22 | assert add_business_days(date(2026, 11, 6), 1) == date(2026, 11, 9) # Fri + 1 = Mon | 28 | assert add_business_days(date(2026, 11, 6), 1) == date(2026, 11, 9) # Fri + 1 = Mon |
Midsummer and All Saints' Day
These three fall on a given weekday within a fixed week, so one small helper covers them.
9. Find a weekday within a seven-day window core
(weekday - start.weekday()) % 7 is the number of days to the next matching weekday, 0 if the window starts on one. FRIDAY and SATURDAY are named because date.weekday() counts from Monday as 0, and a bare 4 doesn't say Friday.
date.weekday()docs โ "Monday is 0 and Sunday is 6"
| 41 | + | |
| 42 | + | |
| 43 | +def _weekday_in_week(year: int, month: int, first_day: int, weekday: int) -> date: | |
| 44 | + """The ``weekday`` in the seven days starting on ``first_day`` of ``month``.""" | |
| 45 | + start = date(year, month, first_day) | |
| 46 | + return start + timedelta(days=(weekday - start.weekday()) % 7) | |
| 47 | + | |
| 16 | 48 |
| 41 | + | ||
| 42 | + | ||
| 43 | +def _weekday_in_week(year: int, month: int, first_day: int, weekday: int) -> date: | ||
| 44 | + """The ``weekday`` in the seven days starting on ``first_day`` of ``month``.""" | ||
| 45 | + start = date(year, month, first_day) | ||
| 46 | + return start + timedelta(days=(weekday - start.weekday()) % 7) | ||
| 47 | + | ||
| 16 | 48 |
10. Midsummer Eve, Midsummer Day and All Saints' Day core
Midsummer Eve is the Friday of 19โ25 June, Midsummer Day the Saturday of 20โ26 June, and All Saints' Day the Saturday of 31 October โ 6 November. The cross-check confirms all three land in their windows on the right weekday for every year from 1583 to 4099.
- assumption: Midsummer Eve counts as a holiday, like Christmas Eve already did. That's how Finnish workplaces run, but it isn't the statutory list.
- question: Should Christmas Eve and Midsummer Eve count by default? A strict statutory option would be easy to add if anyone needs it.
- Public holidays in Finland docs โ The three rules, and Midsummer Eve and Christmas Eve as de facto but not official holidays.
โ
uv run -q --with python-dateutil python -c ' from datetime import date, timedelโฆ โ exit 0 (2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099))
$ uv run -q --with python-dateutil python -c '
from datetime import date, timedelta
from dateutil.easter import easter
from workdays.holidays import easter_sunday, holidays
years = range(1583, 4100)
easter_ok = sum(easter_sunday(y) == easter(y) for y in years)
def rules_hold(y):
h = {name: d for d, names in holidays(y).items() for name in names.split('"'"', '"'"')}
eve, day, saints = h['"'"'Midsummer Eve'"'"'], h['"'"'Midsummer Day'"'"'], h["All Saints'"'"' Day"]
return (eve.weekday() == 4 and date(y, 6, 19) <= eve <= date(y, 6, 25) and day == eve + timedelta(days=1)
and saints.weekday() == 5 and date(y, 10, 31) <= saints <= date(y, 11, 6))
rules_ok = sum(rules_hold(y) for y in years)
print(f'"'"'{easter_ok}/{len(years)} years match dateutil easter(); weekday rules hold in {rules_ok}/{len(years)} (1583-4099)'"'"')
'2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099)
| 62 | + for offset, name in EASTER_HOLIDAYS.items(): | |
| 63 | + add(easter + timedelta(days=offset), name) | |
| 64 | + # Midsummer Eve, like Christmas Eve, is a day off in practice rather than by statute. | |
| 65 | + add(_weekday_in_week(year, 6, 19, FRIDAY), "Midsummer Eve") | |
| 66 | + add(_weekday_in_week(year, 6, 20, SATURDAY), "Midsummer Day") | |
| 67 | + add(_weekday_in_week(year, 10, 31, SATURDAY), "All Saints' Day") | |
| 68 | + return dict(sorted(days.items())) |
| 62 | + for offset, name in EASTER_HOLIDAYS.items(): | ||
| 63 | + add(easter + timedelta(days=offset), name) | ||
| 64 | + # Midsummer Eve, like Christmas Eve, is a day off in practice rather than by statute. | ||
| 65 | + add(_weekday_in_week(year, 6, 19, FRIDAY), "Midsummer Eve") | ||
| 66 | + add(_weekday_in_week(year, 6, 20, SATURDAY), "Midsummer Day") | ||
| 67 | + add(_weekday_in_week(year, 10, 31, SATURDAY), "All Saints' Day") | ||
| 68 | + return dict(sorted(days.items())) |
11. Test a year where all three fall at the end of their window test
In 2027 all three land on the last day of their window, where an off-by-one would show.
โ
uv run -q pytest tests/ -q โ exit 0 (13 passed in 0.01s)
............. [100%] 13 passed in 0.01s
| 47 | + | |
| 48 | + | |
| 49 | +def test_midsummer_and_all_saints_move_with_the_weekday() -> None: | |
| 50 | + days = holidays(2027) | |
| 51 | + assert days[date(2027, 6, 25)] == "Midsummer Eve" | |
| 52 | + assert days[date(2027, 6, 26)] == "Midsummer Day" | |
| 53 | + assert days[date(2027, 11, 6)] == "All Saints' Day" | |
| 54 | + | |
| 55 | + |
| 47 | + | ||
| 48 | + | ||
| 49 | +def test_midsummer_and_all_saints_move_with_the_weekday() -> None: | ||
| 50 | + days = holidays(2027) | ||
| 51 | + assert days[date(2027, 6, 25)] == "Midsummer Eve" | ||
| 52 | + assert days[date(2027, 6, 26)] == "Midsummer Day" | ||
| 53 | + assert days[date(2027, 11, 6)] == "All Saints' Day" | ||
| 54 | + | ||
| 55 | + |
12. Imports mechanical
timedelta for the offsets; pytest and easter_sunday for the new tests.
2 range(s): src/workdays/holidays.py +3 -3, tests/test_holidays.py +3โ6
| 1 | 1 | """Finnish public holidays.""" |
| 2 | 2 | |
| 3 | -from datetime import date | |
| 3 | +from datetime import date, timedelta | |
| 4 | 4 | |
| 5 | 5 | # Christmas Eve is not a statutory holiday, but it is a day off for nearly everyone. |
| 1 | """Finnish public holidays.""" | 1 | """Finnish public holidays.""" |
| 2 | 2 | ||
| 3 | -from datetime import date | 3 | +from datetime import date, timedelta |
| 4 | 4 | ||
| 5 | # Christmas Eve is not a statutory holiday, but it is a day off for nearly everyone. | 5 | # Christmas Eve is not a statutory holiday, but it is a day off for nearly everyone. |
| 1 | 1 | from datetime import date |
| 2 | 2 | |
| 3 | +import pytest | |
| 4 | + | |
| 3 | 5 | from workdays import holidays |
| 6 | +from workdays.holidays import easter_sunday | |
| 4 | 7 | |
| 5 | 8 |
| 1 | from datetime import date | 1 | from datetime import date |
| 2 | 2 | ||
| 3 | +import pytest | ||
| 4 | + | ||
| 3 | from workdays import holidays | 5 | from workdays import holidays |
| 6 | +from workdays.holidays import easter_sunday | ||
| 4 | 7 | ||
| 5 | 8 |
13. Say which holidays count support
Whether the Eve days count changes results, so it shouldn't take reading the code to find out.
| 13 | 13 | ``` |
| 14 | 14 | |
| 15 | +Holidays covered: the fixed-date ones, the Easter-based ones (Good Friday to Whitsunday), Midsummer and All Saints' Day. Christmas Eve and Midsummer Eve are included although they are days off by custom rather than by law. | |
| 16 | + | |
| 15 | 17 | ## Reviewing a pull request |
| 16 | 18 |
| 13 | ``` | 13 | ``` |
| 14 | 14 | ||
| 15 | +Holidays covered: the fixed-date ones, the Easter-based ones (Good Friday to Whitsunday), Midsummer and All Saints' Day. Christmas Eve and Midsummer Eve are included although they are days off by custom rather than by law. | ||
| 16 | + | ||
| 15 | ## Reviewing a pull request | 17 | ## Reviewing a pull request |
| 16 | 18 |
- Should Christmas Eve and Midsummer Eve count by default? A strict statutory option would be easy to add if anyone needs it. (stop 10)
Evidence
โ
uv run -q --with python-dateutil python -c ' from datetime import date, timedelโฆ โ exit 0 (2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099))
$ uv run -q --with python-dateutil python -c '
from datetime import date, timedelta
from dateutil.easter import easter
from workdays.holidays import easter_sunday, holidays
years = range(1583, 4100)
easter_ok = sum(easter_sunday(y) == easter(y) for y in years)
def rules_hold(y):
h = {name: d for d, names in holidays(y).items() for name in names.split('"'"', '"'"')}
eve, day, saints = h['"'"'Midsummer Eve'"'"'], h['"'"'Midsummer Day'"'"'], h["All Saints'"'"' Day"]
return (eve.weekday() == 4 and date(y, 6, 19) <= eve <= date(y, 6, 25) and day == eve + timedelta(days=1)
and saints.weekday() == 5 and date(y, 10, 31) <= saints <= date(y, 11, 6))
rules_ok = sum(rules_hold(y) for y in years)
print(f'"'"'{easter_ok}/{len(years)} years match dateutil easter(); weekday rules hold in {rules_ok}/{len(years)} (1583-4099)'"'"')
'2517/2517 years match dateutil easter(); weekday rules hold in 2517/2517 (1583-4099)
โ
uv run -q pytest tests/ -q โ exit 0 (13 passed in 0.01s)
............. [100%] 13 passed in 0.01s
โ
uv run -q python -c 'from datetime import date; from workdays import add_busineโฆ: before Thu 2 Apr 2026 + 1 business day = 2026-04-03 โ after Thu 2 Apr 2026 + 1 business day = 2026-04-07
$ uv run -q python -c 'from datetime import date; from workdays import add_business_days; print('"'"'Thu 2 Apr 2026 + 1 business day ='"'"', add_business_days(date(2026, 4, 2), 1))'Thu 2 Apr 2026 + 1 business day = 2026-04-03
Thu 2 Apr 2026 + 1 business day = 2026-04-07