From 26551b05cbc99609ce3f52b514fd597d80784b94 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:21:06 -0700 Subject: [PATCH 1/2] Fix weekday modifiers after global calendar layout changes AI assistance was used to prepare the implementation and regression tests. --- docs/docs/modifiers.md | 4 ++ src/pendulum/date.py | 4 +- src/pendulum/datetime.py | 4 +- tests/date/test_day_of_week_modifiers.py | 65 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/docs/docs/modifiers.md b/docs/docs/modifiers.md index 64a3a9948..1944c2e0b 100644 --- a/docs/docs/modifiers.md +++ b/docs/docs/modifiers.md @@ -84,3 +84,7 @@ True # and that accept month, quarter and year units # first_of(), last_of(), nth_of() ``` + +Weekday arguments to `first_of()`, `last_of()` and `nth_of()` use Pendulum's +weekday constants, independently of the standard library's +`calendar.setfirstweekday()` setting. diff --git a/src/pendulum/date.py b/src/pendulum/date.py index 50554f435..78a4b1b77 100644 --- a/src/pendulum/date.py +++ b/src/pendulum/date.py @@ -575,7 +575,7 @@ def _first_of_month(self, day_of_week: WeekDay) -> Self: if day_of_week is None: return dt.set(day=1) - month = calendar.monthcalendar(dt.year, dt.month) + month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week @@ -600,7 +600,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=self.days_in_month) - month = calendar.monthcalendar(dt.year, dt.month) + month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 4a363a500..b47f8f199 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -1026,7 +1026,7 @@ def _first_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=1) - month = calendar.monthcalendar(dt.year, dt.month) + month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week @@ -1049,7 +1049,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=self.days_in_month) - month = calendar.monthcalendar(dt.year, dt.month) + month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week diff --git a/tests/date/test_day_of_week_modifiers.py b/tests/date/test_day_of_week_modifiers.py index 036309679..23f89859b 100644 --- a/tests/date/test_day_of_week_modifiers.py +++ b/tests/date/test_day_of_week_modifiers.py @@ -1,5 +1,9 @@ from __future__ import annotations +import calendar + +from datetime import date + import pytest import pendulum @@ -8,6 +12,67 @@ from tests.conftest import assert_date +@pytest.mark.parametrize("first_weekday", range(7)) +@pytest.mark.parametrize("date_type", [pendulum.Date, pendulum.DateTime]) +def test_weekday_modifiers_ignore_calendar_firstweekday(first_weekday, date_type): + original_first_weekday = calendar.firstweekday() + calendar.setfirstweekday(first_weekday) + try: + for year, month in [(2024, 2), (2025, 2), (2026, 10)]: + instance = date_type(year, month, 15) + if isinstance(instance, pendulum.DateTime): + instance = instance.set( + hour=12, + minute=34, + second=56, + microsecond=789012, + tz="Europe/Paris", + ) + for unit, first_month, last_month in [ + ("month", month, month), + ("quarter", (month - 1) // 3 * 3 + 1, (month - 1) // 3 * 3 + 3), + ("year", 1, 12), + ]: + for weekday in pendulum.WeekDay: + matches = [ + date(year, m, day) + for m in range(first_month, last_month + 1) + for day in range(1, calendar.monthrange(year, m)[1] + 1) + if date(year, m, day).weekday() == weekday + ] + for result, expected in [ + (instance.first_of(unit, weekday), matches[0]), + (instance.last_of(unit, weekday), matches[-1]), + (instance.nth_of(unit, 1, weekday), matches[0]), + (instance.nth_of(unit, 2, weekday), matches[1]), + ]: + assert_date(result, expected.year, expected.month, expected.day) + assert isinstance(result, date_type) + if isinstance(result, pendulum.DateTime): + assert result.tzinfo is instance.tzinfo + assert ( + result.hour + == result.minute + == result.second + == result.microsecond + == 0 + ) + assert calendar.firstweekday() == first_weekday + assert_date(instance.first_of(unit), year, first_month, 1) + assert_date( + instance.last_of(unit), + year, + last_month, + calendar.monthrange(year, last_month)[1], + ) + for method in [instance.first_of, instance.last_of]: + with pytest.raises(IndexError): + method("month", 7) + assert calendar.firstweekday() == first_weekday + finally: + calendar.setfirstweekday(original_first_weekday) + + def test_start_of_week(): d = pendulum.date(1980, 8, 7).start_of("week") assert_date(d, 1980, 8, 4) From b927a70bba0124deec4031e2fe599b92f0357d8b Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:08:24 -0700 Subject: [PATCH 2/2] Reuse private calendars for weekday modifiers --- src/pendulum/date.py | 8 ++++++-- src/pendulum/datetime.py | 8 ++++++-- tests/date/test_day_of_week_modifiers.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/pendulum/date.py b/src/pendulum/date.py index 78a4b1b77..cd703d2ed 100644 --- a/src/pendulum/date.py +++ b/src/pendulum/date.py @@ -31,6 +31,10 @@ from typing_extensions import SupportsIndex +# Weekday modifiers always use Monday-based indices. +_MONTH_CALENDAR = calendar.Calendar(firstweekday=calendar.MONDAY) + + class Date(FormattableMixin, date): _MODIFIERS_VALID_UNITS: ClassVar[list[str]] = [ "day", @@ -575,7 +579,7 @@ def _first_of_month(self, day_of_week: WeekDay) -> Self: if day_of_week is None: return dt.set(day=1) - month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) + month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week @@ -600,7 +604,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=self.days_in_month) - month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) + month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index b47f8f199..96aff25b2 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -46,6 +46,10 @@ from typing_extensions import SupportsIndex +# Weekday modifiers always use Monday-based indices. +_MONTH_CALENDAR = calendar.Calendar(firstweekday=calendar.MONDAY) + + class DateTime(datetime.datetime, Date): EPOCH: ClassVar[DateTime] min: ClassVar[DateTime] @@ -1026,7 +1030,7 @@ def _first_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=1) - month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) + month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week @@ -1049,7 +1053,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self: if day_of_week is None: return dt.set(day=self.days_in_month) - month = calendar.Calendar().monthdayscalendar(dt.year, dt.month) + month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month) calendar_day = day_of_week diff --git a/tests/date/test_day_of_week_modifiers.py b/tests/date/test_day_of_week_modifiers.py index 23f89859b..b8286af67 100644 --- a/tests/date/test_day_of_week_modifiers.py +++ b/tests/date/test_day_of_week_modifiers.py @@ -73,6 +73,18 @@ def test_weekday_modifiers_ignore_calendar_firstweekday(first_weekday, date_type calendar.setfirstweekday(original_first_weekday) +@pytest.mark.parametrize("date_type", [pendulum.Date, pendulum.DateTime]) +def test_weekday_modifiers_reuse_calendar(date_type, monkeypatch): + def unexpected_calendar(*args, **kwargs): + raise AssertionError("Weekday modifiers should reuse their calendar") + + monkeypatch.setattr(calendar, "Calendar", unexpected_calendar) + instance = date_type(2026, 10, 15) + assert_date(instance.first_of("month", pendulum.MONDAY), 2026, 10, 5) + assert_date(instance.last_of("month", pendulum.MONDAY), 2026, 10, 26) + assert_date(instance.nth_of("month", 2, pendulum.MONDAY), 2026, 10, 12) + + def test_start_of_week(): d = pendulum.date(1980, 8, 7).start_of("week") assert_date(d, 1980, 8, 4)