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..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.monthcalendar(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.monthcalendar(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 4a363a500..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.monthcalendar(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.monthcalendar(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 036309679..b8286af67 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,79 @@ 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) + + +@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)