Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: port DateAdjusters #150

Merged
merged 2 commits into from
May 28, 2024
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
131 changes: 121 additions & 10 deletions pyoda_time/_date_adjusters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,134 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import Callable, final

if TYPE_CHECKING:
from . import LocalDate
from ._iso_day_of_week import IsoDayOfWeek
from ._local_date import LocalDate
from ._period import Period
from .utility._csharp_compatibility import _private, _sealed
from .utility._preconditions import _Preconditions


class DateAdjusters:
# TODO: In Noda Time, these are properties which return functions. Any reason not to just use staticmethod?
class __DateAdjustersMeta(type):
@property
def start_of_month(self) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the first day of the current month.

@staticmethod
def end_of_month(date: LocalDate) -> LocalDate:
"""A date adjuster to move to the last day of the current month."""
from . import LocalDate
:return: A date adjuster to move to the first day of the current month.
"""
return lambda date: LocalDate(date.year, date.month, 1, date.calendar)

@property
def end_of_month(self) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the last day of the current month.

return LocalDate(
:return: A date adjuster to move to the last day of the current month.
"""
return lambda date: LocalDate(
year=date.year,
month=date.month,
day=date.calendar.get_days_in_month(date.year, date.month),
calendar=date.calendar,
)


@final
@_sealed
@_private
class DateAdjusters(metaclass=__DateAdjustersMeta):
"""Factory class for date adjusters: functions from ``LocalDate`` to ``LocalDate``,
which can be applied to ``LocalDate``, ``LocalDateTime``, and ``OffsetDateTime``.
"""

@staticmethod
def day_of_month(day: int) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the specified day of the current month.

The returned adjuster will throw an exception if it is applied to a date that would create an invalid result.

:param day: The day of month to adjust dates to.
:return: An adjuster which changes the day to ``day`` retaining the same year and month.
"""
return lambda date: LocalDate(date.year, date.month, day, date.calendar)

@staticmethod
def month(month: int) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the same day of the specified month.

The returned adjuster will throw an exception if it is applied to a date that would create an invalid result.

:param month: The month to adjust dates to.
:return: An adjuster which changes the month to ``month`` retaining the same year and day of month.
"""
return lambda date: LocalDate(date.year, month, date.day, date.calendar)

@staticmethod
def next_or_same(day_of_week: IsoDayOfWeek) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the next specified day-of-week, but return the original date if the day is already
correct.

:param day_of_week: The day-of-week to adjust dates to.
:return: An adjuster which advances a date to the next occurrence of the specified day-of-week, or the original
date if the day is already correct.
"""
if day_of_week < IsoDayOfWeek.MONDAY or day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(f"day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]")
return lambda date: date if date.day_of_week == day_of_week else date.next(day_of_week)

@staticmethod
def previous_or_same(day_of_week: IsoDayOfWeek) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the previous specified day-of-week, but return the original date if the day is
already correct.

:param day_of_week: The day-of-week to adjust dates to.
:return: An adjuster which advances a date to the previous occurrence of the specified day-of-week, or the
original date if the day is already correct.
"""
if day_of_week < IsoDayOfWeek.MONDAY or day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(f"day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]")
return lambda date: date if date.day_of_week == day_of_week else date.previous(day_of_week)

@staticmethod
def next(day_of_week: IsoDayOfWeek) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the next specified day-of-week, adding a week if the day is already correct.

This is the adjuster equivalent of ``LocalDate.next``.

:param day_of_week: The day-of-week to adjust dates to.
:return: An adjuster which advances a date to the next occurrence of the specified day-of-week.
"""
if day_of_week < IsoDayOfWeek.MONDAY or day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(f"day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]")
return lambda date: date.next(day_of_week)

@staticmethod
def previous(day_of_week: IsoDayOfWeek) -> Callable[[LocalDate], LocalDate]:
"""A date adjuster to move to the previous specified day-of-week, subtracting a week if the day is already
correct.

This is the adjuster equivalent of ``LocalDate.previous``.

:param day_of_week: The day-of-week to adjust dates to.
:return: An adjuster which advances a date to the previous occurrence of the specified day-of-week.
"""
if day_of_week < IsoDayOfWeek.MONDAY or day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(f"day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]")
return lambda date: date.previous(day_of_week)

@staticmethod
def add_period(period: Period) -> Callable[[LocalDate], LocalDate]:
"""Creates a date adjuster to add the specified period to the date.

This is the adjuster equivalent of ``LocalDate.plus(Period)``.

:param period: The period to add when the adjuster is invoked. Must not contain any (non-zero) time units.
:return: An adjuster which adds the specified period.
"""
_Preconditions._check_not_null(period, "period")
# Perform this validation eagerly. It will be performed on each invocation as well,
# but it's good to throw an exception now rather than waiting for the first invocation.
_Preconditions._check_argument(
not period.has_time_component, "period", "Cannot add a period with a time component to a date"
)
return lambda date: date + period
57 changes: 56 additions & 1 deletion pyoda_time/_local_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Generator, final, overload
from typing import TYPE_CHECKING, Callable, Generator, final, overload

from ._calendar_ordinal import _CalendarOrdinal
from ._calendar_system import CalendarSystem
Expand Down Expand Up @@ -397,6 +397,50 @@

return _DatePeriodFields._weeks_field.add(self, weeks)

def next(self, target_day_of_week: IsoDayOfWeek) -> LocalDate:
"""Returns the next ``LocalDate`` falling on the specified ``IsoDayOfWeek``.

This is a strict "next" - if this date on already falls on the target day of the week, the returned value will
be a week later.

:param target_day_of_week: The ISO day of the week to return the next date of.
:return: The next ``LocalDate`` falling on the specified day of the week.
:raises RuntimeError: The underlying calendar doesn't use ISO days of the week.
:raises ValueError: ``target_day_of_week`` is not a valid day of the week (Monday to Sunday).
"""
if target_day_of_week < IsoDayOfWeek.MONDAY or target_day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(

Check warning on line 412 in pyoda_time/_local_date.py

View check run for this annotation

Codecov / codecov/patch

pyoda_time/_local_date.py#L412

Added line #L412 was not covered by tests
f"target_day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]"
)
# This will throw the desired exception for calendars with different week systems.
this_day = self.day_of_week
difference = target_day_of_week - this_day
if difference <= 0:
difference += 7
return self.plus_days(difference)

def previous(self, target_day_of_week: IsoDayOfWeek) -> LocalDate:
"""Returns the previous ``LocalDate`` falling on the specified ``IsoDayOfWeek``.

This is a strict "previous" - if this date on already falls on the target day of the week, the returned value
will be a week earlier.

:param target_day_of_week: The ISO day of the week to return the previous date of.
:return: The previous ``LocalDate`` falling on the specified day of the week.
:raises RuntimeError: The underlying calendar doesn't use ISO days of the week.
:raises ValueError: ``target_day_of_week`` is not a valid day of the week (Monday to Sunday).
"""
if target_day_of_week < IsoDayOfWeek.MONDAY or target_day_of_week > IsoDayOfWeek.SUNDAY:
raise ValueError(

Check warning on line 434 in pyoda_time/_local_date.py

View check run for this annotation

Codecov / codecov/patch

pyoda_time/_local_date.py#L434

Added line #L434 was not covered by tests
f"target_day_of_week must be in the range [{IsoDayOfWeek.MONDAY} to {IsoDayOfWeek.SUNDAY}]"
)
# This will throw the desired exception for calendars with different week systems.
this_day = self.day_of_week
difference = target_day_of_week - this_day
if difference >= 0:
difference -= 7
return self.plus_days(difference)

def at(self, time: LocalTime) -> LocalDateTime:
"""Combines this ``LocalDate`` with the given ``LocalTime`` into a single ``LocalDateTime``.

Expand All @@ -407,6 +451,17 @@
"""
return self + time

def with_(self, adjuster: Callable[[LocalDate], LocalDate]) -> LocalDate:
"""Returns this date, with the given adjuster applied to it.

If the adjuster attempts to construct an invalid date (such as by trying to set a day-of-month of 30 in
February), any exception thrown by that construction attempt will be propagated through this method.

:param adjuster: The adjuster to apply.
:return: The adjusted date.
"""
return _Preconditions._check_not_null(adjuster, "adjuster")(self)

def __iter__(self) -> Generator[int, None, None]:
"""Deconstructs the current instance into its components.

Expand Down
158 changes: 158 additions & 0 deletions tests/test_date_adjusters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Copyright 2024 The Pyoda Time Authors. All rights reserved.
# Use of this source code is governed by the Apache License 2.0,
# as found in the LICENSE.txt file.
import pytest

from pyoda_time import CalendarSystem, DateAdjusters, IsoDayOfWeek, LocalDate, Period


class TestDateAdjusters:
def test_start_of_month(self) -> None:
start = LocalDate(2014, 6, 27)
end = LocalDate(2014, 6, 1)
assert DateAdjusters.start_of_month(start) == end

def test_end_of_month(self) -> None:
start = LocalDate(2014, 6, 27)
end = LocalDate(2014, 6, 30)
assert DateAdjusters.end_of_month(start) == end

def test_day_of_month(self) -> None:
start = LocalDate(2014, 6, 27)
end = LocalDate(2014, 6, 19)
adjuster = DateAdjusters.day_of_month(19)
assert adjuster(start) == end

@pytest.mark.parametrize(
"year,month,day,day_of_week,expected_year,expected_month,expected_day",
[
pytest.param(2014, 8, 18, IsoDayOfWeek.MONDAY, 2014, 8, 18, id="Same day-of-week"),
(2014, 8, 18, IsoDayOfWeek.TUESDAY, 2014, 8, 19),
(2014, 8, 18, IsoDayOfWeek.SUNDAY, 2014, 8, 24),
pytest.param(2014, 8, 31, IsoDayOfWeek.MONDAY, 2014, 9, 1, id="Wrap month"),
],
)
def test_next_or_same(
self,
year: int,
month: int,
day: int,
day_of_week: IsoDayOfWeek,
expected_year: int,
expected_month: int,
expected_day: int,
) -> None:
start = LocalDate(year, month, day)
actual = start.with_(DateAdjusters.next_or_same(day_of_week))
expected = LocalDate(expected_year, expected_month, expected_day)
assert actual == expected

@pytest.mark.parametrize(
"year,month,day,day_of_week,expected_year,expected_month,expected_day",
[
pytest.param(2014, 8, 18, IsoDayOfWeek.MONDAY, 2014, 8, 18, id="Same day-of-week"),
(2014, 8, 18, IsoDayOfWeek.TUESDAY, 2014, 8, 12),
(2014, 8, 18, IsoDayOfWeek.SUNDAY, 2014, 8, 17),
pytest.param(2014, 8, 1, IsoDayOfWeek.THURSDAY, 2014, 7, 31, id="Wrap month"),
],
)
def test_previous_or_same(
self,
year: int,
month: int,
day: int,
day_of_week: IsoDayOfWeek,
expected_year: int,
expected_month: int,
expected_day: int,
) -> None:
start = LocalDate(year, month, day)
actual = start.with_(DateAdjusters.previous_or_same(day_of_week))
expected = LocalDate(expected_year, expected_month, expected_day)
assert actual == expected

@pytest.mark.parametrize(
"year,month,day,day_of_week,expected_year,expected_month,expected_day",
[
pytest.param(2014, 8, 18, IsoDayOfWeek.MONDAY, 2014, 8, 25, id="Same day-of-week"),
(2014, 8, 18, IsoDayOfWeek.TUESDAY, 2014, 8, 19),
(2014, 8, 18, IsoDayOfWeek.SUNDAY, 2014, 8, 24),
pytest.param(2014, 8, 31, IsoDayOfWeek.MONDAY, 2014, 9, 1, id="Wrap month"),
],
)
def test_next(
self,
year: int,
month: int,
day: int,
day_of_week: IsoDayOfWeek,
expected_year: int,
expected_month: int,
expected_day: int,
) -> None:
start = LocalDate(year, month, day)
actual = start.with_(DateAdjusters.next(day_of_week))
expected = LocalDate(expected_year, expected_month, expected_day)
assert actual == expected

@pytest.mark.parametrize(
"year,month,day,day_of_week,expected_year,expected_month,expected_day",
[
pytest.param(2014, 8, 18, IsoDayOfWeek.MONDAY, 2014, 8, 11, id="Same day-of-week"),
(2014, 8, 18, IsoDayOfWeek.TUESDAY, 2014, 8, 12),
(2014, 8, 18, IsoDayOfWeek.SUNDAY, 2014, 8, 17),
pytest.param(2014, 8, 1, IsoDayOfWeek.THURSDAY, 2014, 7, 31, id="Wrap month"),
],
)
def test_previous(
self,
year: int,
month: int,
day: int,
day_of_week: IsoDayOfWeek,
expected_year: int,
expected_month: int,
expected_day: int,
) -> None:
start = LocalDate(year, month, day)
actual = start.with_(DateAdjusters.previous(day_of_week))
expected = LocalDate(expected_year, expected_month, expected_day)
assert actual == expected

def test_month_valid(self) -> None:
adjuster = DateAdjusters.month(2)
start = LocalDate(2017, 8, 21, CalendarSystem.julian)
actual = start.with_(adjuster)
expected = LocalDate(2017, 2, 21, CalendarSystem.julian)
assert actual == expected

def test_month_invalid_adjustment(self) -> None:
adjuster = DateAdjusters.month(2)
start = LocalDate(2017, 8, 30, CalendarSystem.julian)
with pytest.raises(ValueError):
start.with_(adjuster)

def test_iso_day_of_week_adjusters_invalid(self) -> None:
invalid = IsoDayOfWeek.NONE
with pytest.raises(ValueError):
DateAdjusters.next(invalid)
with pytest.raises(ValueError):
DateAdjusters.next_or_same(invalid)
with pytest.raises(ValueError):
DateAdjusters.previous(invalid)
with pytest.raises(ValueError):
DateAdjusters.previous_or_same(invalid)

def test_add_period_valid(self) -> None:
period = Period.from_months(1) + Period.from_days(3)
adjuster = DateAdjusters.add_period(period)
start = LocalDate(2019, 5, 4)
assert start.with_(adjuster) == LocalDate(2019, 6, 7)

def test_add_period_null(self) -> None:
with pytest.raises(TypeError):
DateAdjusters.add_period(None) # type: ignore

def test_add_period_including_time_units(self) -> None:
with pytest.raises(ValueError):
DateAdjusters.add_period(Period.from_days(1) + Period.from_hours(1))