Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/docs/modifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 6 additions & 2 deletions src/pendulum/date.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/pendulum/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
77 changes: 77 additions & 0 deletions tests/date/test_day_of_week_modifiers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from __future__ import annotations

import calendar

from datetime import date

import pytest

import pendulum
Expand All @@ -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)
Expand Down
Loading