Skip to content

Commit 24ff0e5

Browse files
authored
Fix weekday modifiers after global calendar layout changes (#1039)
Date and DateTime weekday modifiers use Monday-based weekday constants to index calendar.monthcalendar(). That function follows the standard library's process-wide first-weekday setting, so changing the calendar display layout also changes which dates Pendulum selects: ``` import calendar import pendulum calendar.setfirstweekday(calendar.SUNDAY) pendulum.date(2026, 10, 15).first_of('month', pendulum.MONDAY) # Before: Date(2026, 10, 4), a Sunday # After: Date(2026, 10, 5), a Monday ``` Use a separate Monday-based Calendar instance for the four month-calendar lookups. This fixes first_of, last_of, and the first occurrence in nth_of for month, quarter, and year units. The caller's global calendar setting is never changed. No API or dependency changes.
1 parent b97a3f0 commit 24ff0e5

4 files changed

Lines changed: 93 additions & 4 deletions

File tree

‎docs/docs/modifiers.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,7 @@ True
8484
# and that accept month, quarter and year units
8585
# first_of(), last_of(), nth_of()
8686
```
87+
88+
Weekday arguments to `first_of()`, `last_of()` and `nth_of()` use Pendulum's
89+
weekday constants, independently of the standard library's
90+
`calendar.setfirstweekday()` setting.

‎src/pendulum/date.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
from typing_extensions import SupportsIndex
3232

3333

34+
# Weekday modifiers always use Monday-based indices.
35+
_MONTH_CALENDAR = calendar.Calendar(firstweekday=calendar.MONDAY)
36+
37+
3438
class Date(FormattableMixin, date):
3539
_MODIFIERS_VALID_UNITS: ClassVar[list[str]] = [
3640
"day",
@@ -575,7 +579,7 @@ def _first_of_month(self, day_of_week: WeekDay) -> Self:
575579
if day_of_week is None:
576580
return dt.set(day=1)
577581

578-
month = calendar.monthcalendar(dt.year, dt.month)
582+
month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month)
579583

580584
calendar_day = day_of_week
581585

@@ -600,7 +604,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self:
600604
if day_of_week is None:
601605
return dt.set(day=self.days_in_month)
602606

603-
month = calendar.monthcalendar(dt.year, dt.month)
607+
month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month)
604608

605609
calendar_day = day_of_week
606610

‎src/pendulum/datetime.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
from typing_extensions import SupportsIndex
4747

4848

49+
# Weekday modifiers always use Monday-based indices.
50+
_MONTH_CALENDAR = calendar.Calendar(firstweekday=calendar.MONDAY)
51+
52+
4953
class DateTime(datetime.datetime, Date):
5054
EPOCH: ClassVar[DateTime]
5155
min: ClassVar[DateTime]
@@ -1026,7 +1030,7 @@ def _first_of_month(self, day_of_week: WeekDay | None = None) -> Self:
10261030
if day_of_week is None:
10271031
return dt.set(day=1)
10281032

1029-
month = calendar.monthcalendar(dt.year, dt.month)
1033+
month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month)
10301034

10311035
calendar_day = day_of_week
10321036

@@ -1049,7 +1053,7 @@ def _last_of_month(self, day_of_week: WeekDay | None = None) -> Self:
10491053
if day_of_week is None:
10501054
return dt.set(day=self.days_in_month)
10511055

1052-
month = calendar.monthcalendar(dt.year, dt.month)
1056+
month = _MONTH_CALENDAR.monthdayscalendar(dt.year, dt.month)
10531057

10541058
calendar_day = day_of_week
10551059

‎tests/date/test_day_of_week_modifiers.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import calendar
4+
5+
from datetime import date
6+
37
import pytest
48

59
import pendulum
@@ -8,6 +12,79 @@
812
from tests.conftest import assert_date
913

1014

15+
@pytest.mark.parametrize("first_weekday", range(7))
16+
@pytest.mark.parametrize("date_type", [pendulum.Date, pendulum.DateTime])
17+
def test_weekday_modifiers_ignore_calendar_firstweekday(first_weekday, date_type):
18+
original_first_weekday = calendar.firstweekday()
19+
calendar.setfirstweekday(first_weekday)
20+
try:
21+
for year, month in [(2024, 2), (2025, 2), (2026, 10)]:
22+
instance = date_type(year, month, 15)
23+
if isinstance(instance, pendulum.DateTime):
24+
instance = instance.set(
25+
hour=12,
26+
minute=34,
27+
second=56,
28+
microsecond=789012,
29+
tz="Europe/Paris",
30+
)
31+
for unit, first_month, last_month in [
32+
("month", month, month),
33+
("quarter", (month - 1) // 3 * 3 + 1, (month - 1) // 3 * 3 + 3),
34+
("year", 1, 12),
35+
]:
36+
for weekday in pendulum.WeekDay:
37+
matches = [
38+
date(year, m, day)
39+
for m in range(first_month, last_month + 1)
40+
for day in range(1, calendar.monthrange(year, m)[1] + 1)
41+
if date(year, m, day).weekday() == weekday
42+
]
43+
for result, expected in [
44+
(instance.first_of(unit, weekday), matches[0]),
45+
(instance.last_of(unit, weekday), matches[-1]),
46+
(instance.nth_of(unit, 1, weekday), matches[0]),
47+
(instance.nth_of(unit, 2, weekday), matches[1]),
48+
]:
49+
assert_date(result, expected.year, expected.month, expected.day)
50+
assert isinstance(result, date_type)
51+
if isinstance(result, pendulum.DateTime):
52+
assert result.tzinfo is instance.tzinfo
53+
assert (
54+
result.hour
55+
== result.minute
56+
== result.second
57+
== result.microsecond
58+
== 0
59+
)
60+
assert calendar.firstweekday() == first_weekday
61+
assert_date(instance.first_of(unit), year, first_month, 1)
62+
assert_date(
63+
instance.last_of(unit),
64+
year,
65+
last_month,
66+
calendar.monthrange(year, last_month)[1],
67+
)
68+
for method in [instance.first_of, instance.last_of]:
69+
with pytest.raises(IndexError):
70+
method("month", 7)
71+
assert calendar.firstweekday() == first_weekday
72+
finally:
73+
calendar.setfirstweekday(original_first_weekday)
74+
75+
76+
@pytest.mark.parametrize("date_type", [pendulum.Date, pendulum.DateTime])
77+
def test_weekday_modifiers_reuse_calendar(date_type, monkeypatch):
78+
def unexpected_calendar(*args, **kwargs):
79+
raise AssertionError("Weekday modifiers should reuse their calendar")
80+
81+
monkeypatch.setattr(calendar, "Calendar", unexpected_calendar)
82+
instance = date_type(2026, 10, 15)
83+
assert_date(instance.first_of("month", pendulum.MONDAY), 2026, 10, 5)
84+
assert_date(instance.last_of("month", pendulum.MONDAY), 2026, 10, 26)
85+
assert_date(instance.nth_of("month", 2, pendulum.MONDAY), 2026, 10, 12)
86+
87+
1188
def test_start_of_week():
1289
d = pendulum.date(1980, 8, 7).start_of("week")
1390
assert_date(d, 1980, 8, 4)

0 commit comments

Comments
 (0)