From 7783a030b4c2fb03b5b319c929659a8ec1b86e22 Mon Sep 17 00:00:00 2001 From: Chris McEvoy <12284065+chrisimcevoy@users.noreply.github.com> Date: Fri, 24 Jan 2025 19:24:28 +0000 Subject: [PATCH] feat: Implement `LocalDate`, `OffsetDate` and `DateInterval` (#252) * feat: Implement `LocalDate`, `OffsetDate` and `DateInterval` * test: add missing test & update coverage --- pyoda_time/__init__.py | 2 + pyoda_time/_date_interval.py | 188 ++++++- pyoda_time/_local_date.py | 199 +++++++- pyoda_time/_local_date_time.py | 2 + pyoda_time/_offset_date.py | 209 ++++++++ tests/helpers.py | 18 +- tests/test_comparison_operator_consistency.py | 2 + tests/test_date_interval.py | 337 +++++++++++++ tests/test_local_date.py | 468 +++++++++++++++++- tests/test_offset_date.py | 103 ++++ 10 files changed, 1513 insertions(+), 15 deletions(-) create mode 100644 pyoda_time/_offset_date.py create mode 100644 tests/test_date_interval.py create mode 100644 tests/test_offset_date.py diff --git a/pyoda_time/__init__.py b/pyoda_time/__init__.py index 7330b90..8c51d47 100644 --- a/pyoda_time/__init__.py +++ b/pyoda_time/__init__.py @@ -22,6 +22,7 @@ "LocalDateTime", "LocalTime", "Offset", + "OffsetDate", "OffsetDateTime", "OffsetTime", "Period", @@ -74,6 +75,7 @@ from ._local_date_time import LocalDateTime from ._local_time import LocalTime from ._offset import Offset +from ._offset_date import OffsetDate from ._offset_date_time import OffsetDateTime from ._offset_time import OffsetTime from ._period import Period diff --git a/pyoda_time/_date_interval.py b/pyoda_time/_date_interval.py index 91ce74c..6320516 100644 --- a/pyoda_time/_date_interval.py +++ b/pyoda_time/_date_interval.py @@ -4,10 +4,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, final +from typing import TYPE_CHECKING, final, overload + +from ._local_date import LocalDate +from ._period import Period +from .text import LocalDatePattern if TYPE_CHECKING: - from . import LocalDate + from collections.abc import Iterator + + from . import CalendarSystem from .utility._csharp_compatibility import _sealed from .utility._preconditions import _Preconditions @@ -18,7 +24,17 @@ @_sealed @final class DateInterval: - """An interval between two dates.""" + """An interval between two dates. + + Equality is defined in a component-wise fashion: two date intervals are considered equal if their start dates are + equal to each other and their end dates are equal to each other. Ordering between date intervals is not defined. + + The two dates must be in the same calendar, and the end date must not be earlier than the start date. + + The end date is deemed to be part of the range, as this matches many real life uses of + date ranges. For example, if someone says "I'm going to be on holiday from Monday to Friday," they + usually mean that Friday is part of their holiday. + """ @property def start(self) -> LocalDate: @@ -44,7 +60,15 @@ def __init__(self, start: LocalDate, end: LocalDate) -> None: self.__end: LocalDate = end def __hash__(self) -> int: - return hash((self.start, self.__end)) + """Returns the hash code for this interval, consistent with ``__eq__()``. + + See the type documentation for a description of equality semantics. + + :return: The hash code for this interval. + """ + from pyoda_time.utility._hash_code_helper import _hash_code_helper + + return _hash_code_helper(self.start, self.end) def __eq__(self, other: object) -> bool: if self is other: @@ -60,3 +84,159 @@ def __ne__(self, other: object) -> bool: def equals(self, other: DateInterval) -> bool: return self == other + + def __contains__(self, item: LocalDate | DateInterval) -> bool: + if isinstance(item, LocalDate): + _Preconditions._check_argument( + item.calendar == self.__start.calendar, + "item", + "The date to check must be in the same calendar as the start and end dates", + ) + return self.__start <= item <= self.__end + elif isinstance(item, DateInterval): + self.__validate_interval(item) + return self.__start <= item.__start and item.__end <= self.__end + raise TypeError(f"item must be one of LocalDate or DateInterval; got {item.__class__.__name__}") + + @overload + def contains(self, date: LocalDate, /) -> bool: + """Checks whether the given date is within this date interval. + + This requires that the date is not earlier than the start date, and not later than the end date. + + Friendly alternative to ``__contains__()``. + + :param date: The date to check for containment within this interval. + :return: ``True`` if ``date`` is within this interval; ``False`` otherwise. + """ + + @overload + def contains(self, interval: DateInterval, /) -> bool: + """Checks whether the given interval is within this interval. + + This requires that the start date of the specified interval is not earlier than the start date of this interval, + and the end date of the specified interval is not later than the end date of this interval. + + Friendly alternative to ``__contains__()``. + + :param interval: The interval to check for containment within this interval. + :return: ``True`` if ``interval`` is within this interval; ``False`` otherwise. + """ + + @overload + def contains(self, date: LocalDate | DateInterval, /) -> bool: ... + + def contains(self, date_or_interval: LocalDate | DateInterval, /) -> bool: + return date_or_interval in self + + def __len__(self) -> int: + """Return the length of the interval in days.""" + # Period.InternalDaysBetween will give us the exclusive result, so we need to add 1 + # to include the end date. + + return Period._internal_days_between(self.__start, self.__end) + 1 + + @property + def calendar(self) -> CalendarSystem: + """The calendar system of the dates in this interval.""" + return self.__start.calendar + + def __repr__(self) -> str: + pattern = LocalDatePattern.iso + # TODO: Invariant + return f"[{pattern.format(self.__start)}, {pattern.format(self.__end)}]" + + # def __iter__(self) -> Iterator[LocalDate]: + # """Deconstruct this date interval into its components.""" + # yield self.__start + # yield self.__end + + # Different to Noda Time: In Python I think users will reasonably expect & to work. + def __and__(self, interval: DateInterval) -> DateInterval | None: + """Return the intersection between the given interval and this interval. + + :param interval: The specified interval to intersect with this one. + :return: A ``DateInterval`` corresponding to the intersection between the given interval and the current + instance. If there is no intersection, ``None`` is returned. + :raises ValueError: ``interval`` uses a different calendar to this date interval. + """ + if interval in self: + return interval + if self in interval: + return self + if self.__start in interval: + return DateInterval(self.__start, interval.__end) + if self.__end in interval: + return DateInterval(interval.__start, self.__end) + return None + + def intersection(self, interval: DateInterval) -> DateInterval | None: + """Return the intersection between the given interval and this interval. + + Friendly alternative to ``__and__()``. + + :param interval: The specified interval to intersect with this one. + :return: A ``DateInterval`` corresponding to the intersection between the given interval and the current + instance. If there is no intersection, ``None`` is returned. + :raises ValueError: ``interval`` uses a different calendar to this date interval. + """ + return self & interval + + # Different to Noda Time: In Python I think users will reasonably expect | to work. + def __or__(self, interval: DateInterval) -> DateInterval | None: + """Return the union between the given interval and this interval, as long as they're overlapping or contiguous. + + :param interval: The specified interval from which to generate the union interval. + :return: A ``DateInterval`` corresponding to the union between the given interval and the current instance, in + the case the intervals overlap or are contiguous; None otherwise. + :raises ValueError: ``interval`` uses a different calendar to this date interval. + """ + self.__validate_interval(interval) + + start = LocalDate.min(self.__start, interval.__start) + end = LocalDate.max(self.__end, interval.__end) + + # Check whether the length of the interval we *would* construct is greater + # than the sum of the lengths - if it is, there's a day in that candidate union + # that isn't in either interval. Note the absence of "+ 1" and the use of >= + # - it's equivalent to Period.InternalDaysBetween(...) + 1 > Length + interval.Length, + # but with fewer operations. + if Period.days_between(start, end) >= len(self) + len(interval): + return None + return DateInterval(start, end) + + def union(self, interval: DateInterval) -> DateInterval | None: + """Return the union between the given interval and this interval, as long as they're overlapping or contiguous. + + Friendly alternative to ``__or__()``. + + :param interval: The specified interval from which to generate the union interval. + :return: A ``DateInterval`` corresponding to the union between the given interval and the current instance, in + the case the intervals overlap or are contiguous; None otherwise. + :raises ValueError: ``interval`` uses a different calendar to this date interval. + """ + return self | interval + + def __validate_interval(self, interval: DateInterval) -> None: + _Preconditions._check_not_null(interval, "interval") + _Preconditions._check_argument( + interval.calendar == self.__start.calendar, + "interval", + "The specified interval uses a different calendar system to this one", + ) + + def __iter__(self) -> Iterator[LocalDate]: + """Returns an iterator for the dates in the interval, including both ``start`` and ``end``. + + :return: An iterator for the interval. + """ + # Stop when we know we've reach End, and then yield that. + # We can't use a <= condition, as otherwise we'd try to create a date past End, which may be invalid. + # We could use < but that's significantly less efficient than != + # We know that adding a day at a time we'll eventually reach End (because they're validated to be in the same + # calendar system, with Start <= End), so that's the simplest way to go. + days_to_add = 0 + while (date := self.__start.plus_days(days_to_add)) != self.__end: + yield date + days_to_add += 1 + yield self.__end diff --git a/pyoda_time/_local_date.py b/pyoda_time/_local_date.py index a1cb756..e88e1ba 100644 --- a/pyoda_time/_local_date.py +++ b/pyoda_time/_local_date.py @@ -10,6 +10,7 @@ from ._calendar_ordinal import _CalendarOrdinal from ._calendar_system import CalendarSystem from ._iso_day_of_week import IsoDayOfWeek +from ._period import Period from .calendars import Era, WeekYearRules from .utility._csharp_compatibility import _sealed from .utility._preconditions import _Preconditions @@ -17,7 +18,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from . import DateTimeZone, LocalDateTime, LocalTime, Period, YearMonth, ZonedDateTime + from . import DateTimeZone, LocalDateTime, LocalTime, Offset, OffsetDate, YearMonth, ZonedDateTime from ._year_month_day import _YearMonthDay from ._year_month_day_calendar import _YearMonthDayCalendar @@ -60,8 +61,15 @@ def min_iso_value(self) -> LocalDate: @final @_sealed class LocalDate(metaclass=_LocalDateMeta): - """LocalDate is an immutable struct representing a date within the calendar, with no reference to a particular time - zone or time of day.""" + """LocalDate is an immutable object representing a date within the calendar, with no reference to a particular time + zone or time of day. + + Values can freely be compared for equality: a value in a different calendar system is not equal to a value in a + different calendar system. However, ordering comparisons fail with ``ValueError``; attempting to compare values in + different calendars almost always indicates a bug in the calling code. + + The default value of this type is 0001-01-01 (January 1st, 1 C.E.) in the ISO calendar. + """ def __init__( self, @@ -99,15 +107,21 @@ def __init__( @classmethod @overload - def _ctor(cls, *, year_month_day_calendar: _YearMonthDayCalendar) -> LocalDate: ... + def _ctor(cls, *, year_month_day_calendar: _YearMonthDayCalendar) -> LocalDate: + """Constructs an instance from values which are assumed to already have been validated.""" @classmethod @overload - def _ctor(cls, *, days_since_epoch: int) -> LocalDate: ... + def _ctor(cls, *, days_since_epoch: int) -> LocalDate: + """Constructs an instance from the number of days since the unix epoch, in the ISO calendar system.""" @classmethod @overload - def _ctor(cls, *, days_since_epoch: int, calendar: CalendarSystem) -> LocalDate: ... + def _ctor(cls, *, days_since_epoch: int, calendar: CalendarSystem) -> LocalDate: + """Constructs an instance from the number of days since the unix epoch, and a calendar system. + + The calendar system is assumed to be non-null, but the days since the epoch are validated. + """ @classmethod def _ctor( @@ -161,6 +175,7 @@ def month(self) -> int: @property def day(self) -> int: + """The day of this local date within the month.""" return self.__year_month_day_calendar._day @property @@ -289,6 +304,110 @@ def __add__(self, other: LocalTime | Period) -> LocalDateTime | LocalDate: return LocalDateTime._ctor(local_date=self, local_time=other) return NotImplemented # type: ignore[unreachable] + @staticmethod + def add(date: LocalDate, period: Period) -> LocalDate: + """Adds the specified period to the date. Fields are added in descending order of significance (years first, + then months, and so on). Friendly alternative to ``+``. + + :param date: The date to add the period to. + :param period: The period to add. Must not contain any (non-zero) time units. + :return: The sum of the given date and period. + """ + return date + period + + def plus(self, period: Period) -> LocalDate: + """Adds the specified period to this date. Fields are added in descending order of significance (years first, + then months, and so on). Fluent alternative to ``+``. + + :param period: The period to add. Must not contain any (non-zero) time units. + :return: The sum of this date and the given period. + """ + return self + period + + @overload + def __sub__(self, other: LocalDate) -> Period: + """Subtracts one date from another, returning the result as a ``Period`` with units of years, months and days. + + This is simply a convenience operator for calling ``Period.between(LocalDate, LocalDate)``. + The calendar systems of the two dates must be the same; an exception will be thrown otherwise. + + :param other: The date to subtract. + :return: The result of subtracting one date from another. + :exception ValueError: The two dates are not in the same calendar system. + """ + + @overload + def __sub__(self, other: Period) -> LocalDate: + """Subtracts the specified period from the date. Fields are subtracted in descending order of significance + (years first, then months, and so on). This is a convenience operator over the ``minus(Period)`` method. + + :param other: The period to subtract. Must not contain any (non-zero) time units. + :return: The result of subtracting the given period from the date. + """ + + def __sub__(self, other: LocalDate | Period) -> LocalDate | Period: + if isinstance(other, LocalDate): + return Period.between(other, self) + elif isinstance(other, Period): + _Preconditions._check_argument( + not other.has_time_component, "period", "Cannot subtract a period with a time component from a date" + ) + return ( + self.plus_years(-other.years).plus_months(-other.months).plus_weeks(-other.weeks).plus_days(-other.days) + ) + return NotImplemented # type: ignore[unreachable] + + @staticmethod + @overload + def subtract(date: LocalDate, period: Period, /) -> LocalDate: + """Subtracts the specified period from the date. Fields are subtracted in descending order of significance + (years first, then months, and so on). Friendly alternative to ``-``. + + :param date: The date to subtract the period from. + :param period: The period to subtract. Must not contain any (non-zero) time units. + :return: The result of subtracting the given period from the date. + """ + + @staticmethod + @overload + def subtract(lhs: LocalDate, rhs: LocalDate, /) -> Period: + """Subtracts one date from another, returning the result as a ``Period`` with units of years, months and days. + + This is simply a convenience method for calling ``Period.between(LocalDate, LocalDate)``. + The calendar systems of the two dates must be the same. + + :param lhs: The date to subtract from. + :param rhs: The date to subtract. + :return: The result of subtracting one date from another. + """ + + @staticmethod + def subtract(date: LocalDate, other: LocalDate | Period, /) -> LocalDate | Period: + return date - other + + @overload + def minus(self, period: Period, /) -> LocalDate: + """Subtracts the specified period from this date. Fields are subtracted in descending order of significance + (years first, then months, and so on). Fluent alternative to ``-``. + + :param period: The period to subtract. Must not contain any (non-zero) time units. + :return: The result of subtracting the given period from this date. + """ + + @overload + def minus(self, date: LocalDate, /) -> Period: + """Subtracts the specified date from this date, returning the result as a ``Period`` with units of years, months + and days. Fluent alternative to ``-``. + + The specified date must be in the same calendar system as this. + + :param date: The date to subtract from this. + :return: The difference between the specified date and this one. + """ + + def minus(self, period: LocalDate | Period, /) -> LocalDate | Period: + return self - period + def __eq__(self, other: object) -> bool: if not isinstance(other, LocalDate): return NotImplemented @@ -342,6 +461,8 @@ def __ge__(self, other: LocalDate) -> bool: def compare_to(self, other: LocalDate | None) -> int: if other is None: return 1 + if not isinstance(other, LocalDate): + raise TypeError(f"{self.__class__.__name__} cannot be compared to {other.__class__.__name__}") _Preconditions._check_argument( self.__calendar_ordinal == other.__calendar_ordinal, "other", @@ -356,6 +477,43 @@ def __trusted_compare_to(self, other: LocalDate) -> int: """ return self.calendar._compare(self._year_month_day, other._year_month_day) + @classmethod + def max(cls, x: LocalDate, y: LocalDate) -> LocalDate: + """Returns the later date of the given two. + + :param x: The first date to compare. + :param y: The second date to compare. + :raises ValueError: The two dates have different calendar systems. + :return: The later date of x or y. + """ + _Preconditions._check_argument( + x.calendar == y.calendar, "y", "Only values with the same calendar system can be compared" + ) + return max(x, y) + + @classmethod + def min(cls, x: LocalDate, y: LocalDate) -> LocalDate: + """Returns the earlier date of the given two. + + :param x: The first date to compare. + :param y: The second date to compare. + :raises ValueError: The two dates have different calendar systems. + :return: The earlier date of x or y. + """ + _Preconditions._check_argument( + x.calendar == y.calendar, "y", "Only values with the same calendar system can be compared" + ) + return min(x, y) + + def __hash__(self) -> int: + """Returns a hash code for this local date. + + See the type documentation for a description of equality semantics. + + :return: A hash code for this local date. + """ + return hash(self.__year_month_day_calendar) + def at_start_of_day_in_zone(self, zone: DateTimeZone) -> ZonedDateTime: """Resolves this local date into a ``ZonedDateTime`` in the given time zone representing the start of this date in the given zone. @@ -414,11 +572,24 @@ def plus_months(self, months: int) -> LocalDate: return _DatePeriodFields._months_field.add(self, months) def plus_days(self, days: int) -> LocalDate: + """Returns a new LocalDate representing the current value with the given number of days added. + + This method does not try to maintain the month or year of the current value, so adding 3 days to a value of + January 30th will result in a value of February 2nd. + + :param days: The number of days to add. + :return: The current value plus the given number of days. + """ from .fields._date_period_fields import _DatePeriodFields return _DatePeriodFields._days_field.add(self, days) def plus_weeks(self, weeks: int) -> LocalDate: + """Returns a new LocalDate representing the current value with the given number of weeks added. + + :param weeks: The number of weeks to add. + :return: The current value plus the given number of weeks. + """ from .fields._date_period_fields import _DatePeriodFields return _DatePeriodFields._weeks_field.add(self, weeks) @@ -435,7 +606,7 @@ def next(self, target_day_of_week: IsoDayOfWeek) -> LocalDate: :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( + raise ValueError( # pragma: no cover 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. @@ -457,7 +628,7 @@ def previous(self, target_day_of_week: IsoDayOfWeek) -> LocalDate: :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( + raise ValueError( # pragma: no cover 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. @@ -467,6 +638,18 @@ def previous(self, target_day_of_week: IsoDayOfWeek) -> LocalDate: difference -= 7 return self.plus_days(difference) + def with_offset(self, offset: Offset) -> OffsetDate: + """Returns an ``OffsetDate`` for this local date with the given offset. + + This method is purely a convenient alternative to calling the ``OffsetDate`` constructor directly. + + :param offset: The offset to apply. + :return: The result of this date offset by the given amount. + """ + from . import OffsetDate + + return OffsetDate(self, offset) + def at(self, time: LocalTime) -> LocalDateTime: """Combines this ``LocalDate`` with the given ``LocalTime`` into a single ``LocalDateTime``. diff --git a/pyoda_time/_local_date_time.py b/pyoda_time/_local_date_time.py index 32f6878..421dbe5 100644 --- a/pyoda_time/_local_date_time.py +++ b/pyoda_time/_local_date_time.py @@ -396,6 +396,8 @@ def __ge__(self, other: LocalDateTime) -> bool: def compare_to(self, other: LocalDateTime | None) -> int: if other is None: return 1 + if not isinstance(other, LocalDateTime): + raise TypeError(f"{self.__class__.__name__} cannot be compared to {other.__class__.__name__}") # This will check calendars... date_comparison = self.__date.compare_to(other.__date) if date_comparison != 0: diff --git a/pyoda_time/_offset_date.py b/pyoda_time/_offset_date.py new file mode 100644 index 0000000..fdf0e04 --- /dev/null +++ b/pyoda_time/_offset_date.py @@ -0,0 +1,209 @@ +# 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. + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final, final + +from pyoda_time._local_date import LocalDate +from pyoda_time._offset import Offset +from pyoda_time._offset_date_time import OffsetDateTime +from pyoda_time.utility._hash_code_helper import _hash_code_helper + +if TYPE_CHECKING: + from collections.abc import Callable + + from pyoda_time import CalendarSystem, IsoDayOfWeek, LocalTime + from pyoda_time.calendars import Era + + +@final +class OffsetDate: + """A combination of a ``LocalDate`` and an ``Offset``, to represent a date at a specific offset from UTC but without + any time-of-day information. + + Equality is defined in a component-wise fashion: two values are the same if they represent equal dates + (including being in the same calendar) and equal offsets from UTC. + + The default value of this type is 0001-01-01 (January 1st, 1 C.E.) in the ISO calendar with a UTC offset of zero. + """ + + def __init__(self, date: LocalDate = LocalDate(), offset: Offset = Offset()) -> None: + """Constructs an instance of the specified date and offset. + + :param date: The date part of the value. + :param offset: The offset part of the value. + """ + self.__date: Final[LocalDate] = date + self.__offset: Final[Offset] = offset + + @property + def date(self) -> LocalDate: + """Gets the local date represented by this value. + + :return: The local date represented by this value. + """ + return self.__date + + @property + def offset(self) -> Offset: + """Gets the offset from UTC of this value. + + :return: The offset from UTC of this value. + """ + return self.__offset + + @property + def calendar(self) -> CalendarSystem: + """Gets the calendar system associated with this offset date. + + :return: The calendar system associated with this offset date. + """ + return self.date.calendar + + @property + def year(self) -> int: + """Gets the year of this offset date. + + This returns the "absolute year", so, for the ISO calendar, a value of 0 means 1 BC, for example. + + :return: The year of this offset date. + """ + return self.__date.year + + @property + def month(self) -> int: + """Gets the month of this offset date within the year. + + :return: The month of this offset date within the year. + """ + return self.__date.month + + @property + def day(self) -> int: + """Gets the day of this offset date within the month. + + :return: The day of this offset date within the month. + """ + return self.__date.day + + @property + def day_of_week(self) -> IsoDayOfWeek: + """Gets the week day of this offset date expressed as an ``IsoDayOfWeek`` value. + + :return: The week day of this offset date expressed as an ``IsoDayOfWeek`` value. + """ + return self.__date.day_of_week + + @property + def year_of_era(self) -> int: + """Gets the year of this offset date within the era. + + :return: The year of this offset date within the era. + """ + return self.__date.year_of_era + + @property + def era(self) -> Era: + """Gets the era of this offset date. + + :return: The era of this offset date. + """ + return self.__date.era + + @property + def day_of_year(self) -> int: + """Gets the day of this offset date within the year. + + :return: The day of this offset date within the year. + """ + return self.__date.day_of_year + + def with_offset(self, offset: Offset) -> OffsetDate: + """Creates a new ``OffsetDate`` for the same date, but with the specified UTC offset. + + :param offset: The new UTC offset. + :return: A new ``OffsetDate`` for the same date, but with the specified UTC offset. + """ + return OffsetDate(self.__date, offset) + + def with_(self, adjuster: Callable[[LocalDate], LocalDate]) -> OffsetDate: + """Returns this offset date, with the given date adjuster applied to it, maintaining the existing offset. + + 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 offset date. + """ + return OffsetDate(date=self.__date.with_(adjuster), offset=self.__offset) + + def with_calendar(self, calendar: CalendarSystem) -> OffsetDate: + """Creates a new ``OffsetDate`` representing the same physical date and offset, but in a different calendar. + + The returned value is likely to have different date field values to this one. + For example, January 1st 1970 in the Gregorian calendar was December 19th 1969 in the Julian calendar. + + :param calendar: The calendar system to convert this offset date to. + :return: The converted ``OffsetDate``. + """ + return OffsetDate(date=self.__date.with_calendar(calendar), offset=self.__offset) + + def at(self, time: LocalTime) -> OffsetDateTime: + """Combines this ``OffsetDate`` with the given ``LocalTime`` into an ``OffsetDateTime``. + + :param time: The time to combine with this date. + :return: The ``OffsetDateTime`` representation of the given time on this date. + """ + return OffsetDateTime(local_date_time=self.__date.at(time=time), offset=self.__offset) + + def __hash__(self) -> int: + """Returns a hash code for this offset date. + + See the type documentation for a description of equality semantics. + + :return: A hash code for this offset date. + """ + return _hash_code_helper(self.__date, self.__offset) + + def equals(self, other: OffsetDate) -> bool: + """Compares two ``OffsetDate`` values for equality. + + See the type documentation for a description of equality semantics. + + :param other: The value to compare this offset date with. + :return: True if the given value is another offset date equal to this one; false otherwise. + """ + return self == other + + def __eq__(self, other: object) -> bool: + """Implements the operator == (equality). + + See the type documentation for a description of equality semantics. + + :param other: The value to compare this offset date with. + :return: ``True`` if values are equal to each other, otherwise ``False``. + """ + if not isinstance(other, OffsetDate): + return NotImplemented + return self.__date == other.__date and self.__offset == other.__offset + + def __ne__(self, other: object) -> bool: + """Implements the operator != (inequality). + + See the type documentation for a description of equality semantics. + + :param other: The value to compare this offset date with. + :return: ``True`` if values are not equal to each other, otherwise ``False``. + """ + if not isinstance(other, OffsetDate): + return NotImplemented + return not (self == other) + + # TODO: def __repr__(self) -> str: [requires OffsetDatePattern] + # TODO: deconstruct? + # TODO: XML Serialization...? + + +__all__ = ["OffsetDate"] diff --git a/tests/helpers.py b/tests/helpers.py index 148fc5b..65be8e7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -72,8 +72,24 @@ def assert_valid(func: Callable[..., TOut], *args: TArg) -> TOut: def assert_overflow(func: Callable[[TArg], TOut], param: TArg) -> None: - with pytest.raises(OverflowError): + """Asserts that the given operation throws one of InvalidOperationException, ArgumentException (including + ArgumentOutOfRangeException) or OverflowException. + + (It's hard to always be consistent bearing in mind one method calling another.) + """ + try: func(param) + pytest.fail( # pragma: no cover + "Expected OverflowException, ArgumentException, ArgumentOutOfRangeException or InvalidOperationException" + ) + except OverflowError: + pass + except ValueError as e: + assert e.__class__ is ValueError, ( + "Exception should not be a subtype of ArgumentException, other than ArgumentOutOfRangeException" + ) + except RuntimeError: # pragma: no cover + pass def test_compare_to_struct(value: T_IComparable, equal_value: T_IComparable, *greater_values: T_IComparable) -> None: diff --git a/tests/test_comparison_operator_consistency.py b/tests/test_comparison_operator_consistency.py index 4f4a031..5ce364a 100644 --- a/tests/test_comparison_operator_consistency.py +++ b/tests/test_comparison_operator_consistency.py @@ -22,6 +22,7 @@ LocalDateTime, LocalTime, Offset, + OffsetDate, OffsetDateTime, OffsetTime, Period, @@ -57,6 +58,7 @@ LocalDate.max_iso_value + LocalTime.max_value, MapZone("windowsId", "territory", ["tzdbId1"]), Offset.zero, + OffsetDate(LocalDate.min_iso_value, Offset.zero), OffsetDateTime(LocalDateTime(), Offset.zero), OffsetTime(LocalTime.midnight, Offset.zero), Period.zero, diff --git a/tests/test_date_interval.py b/tests/test_date_interval.py new file mode 100644 index 0000000..15c23a2 --- /dev/null +++ b/tests/test_date_interval.py @@ -0,0 +1,337 @@ +# 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. +from typing import Final + +import pytest + +from pyoda_time import CalendarSystem, DateInterval, Instant, LocalDate +from pyoda_time.text import LocalDatePattern + + +class TestDateInterval: + JULIAN_CALENDAR: Final[CalendarSystem] = CalendarSystem.julian + + def test_construction_different_calendars(self) -> None: + start: LocalDate = LocalDate(1600, 1, 1) + end: LocalDate = LocalDate(1800, 1, 1, self.JULIAN_CALENDAR) + with pytest.raises(ValueError): # TODO: ArgumentException + DateInterval(start, end) + + def test_construction_end_before_start(self) -> None: + start: LocalDate = LocalDate(1600, 1, 1) + end: LocalDate = LocalDate(1500, 1, 1) + with pytest.raises(ValueError): # TODO: ArgumentException + DateInterval(start, end) + + def test_equal_start_and_end(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + # In Noda Time this is wrapped in `Assert.DoesNotThrow()` + DateInterval(start, start) + + def test_construction_properties(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval = DateInterval(start, end) + assert interval.start == start + assert interval.end == end + + def test_equals_same_instance(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval = DateInterval(start, end) + + # In Noda Time, this looks a wee bit different owing to the differing types of equality check in C# + # (Object.Equals(), IEquatable.Equals()) + assert interval == interval # noqa: PLR0124 + assert hash(interval) == hash(interval) + assert not (interval != interval) # noqa: PLR0124 + assert interval is interval # noqa: PLR0124 + + def test_equals_equal_values(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval1 = DateInterval(start, end) + interval2 = DateInterval(start, end) + + assert interval1.equals(interval2) + assert hash(interval1) == hash(interval2) + assert interval1 == interval2 + assert not (interval1 != interval2) + + def test_equals_different_calendars(self) -> None: + start1: LocalDate = LocalDate(2000, 1, 1) + end1: LocalDate = LocalDate(2001, 6, 19) + # This is a really, really similar calendar to ISO, but we do distinguish. + start2: LocalDate = start1.with_calendar(CalendarSystem.gregorian) + end2: LocalDate = end1.with_calendar(CalendarSystem.gregorian) + interval1 = DateInterval(start1, end1) + interval2 = DateInterval(start2, end2) + + assert not interval1.equals(interval2) + assert hash(interval1) != hash(interval2) + assert not (interval1 == interval2) + assert interval1 != interval2 + + def test_equals_different_start(self) -> None: + start1: LocalDate = LocalDate(2000, 1, 1) + start2: LocalDate = LocalDate(2001, 1, 2) + end = LocalDate(2001, 6, 19) + interval1 = DateInterval(start1, end) + interval2 = DateInterval(start2, end) + + assert not interval1.equals(interval2) + assert hash(interval1) != hash(interval2) + assert not (interval1 == interval2) + assert interval1 != interval2 + + def test_equals_different_end(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end1 = LocalDate(2001, 6, 19) + end2 = LocalDate(2001, 6, 20) + interval1 = DateInterval(start, end1) + interval2 = DateInterval(start, end2) + + assert not interval1.equals(interval2) + assert hash(interval1) != hash(interval2) + assert not (interval1 == interval2) + assert interval1 != interval2 + + def test_equals_different_to_null(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval = DateInterval(start, end) + + assert not interval.equals(None) # type: ignore[arg-type] + + def test_equals_different_to_other_type(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval = DateInterval(start, end) + + assert not interval.equals(Instant.from_unix_time_ticks(0)) # type: ignore[arg-type] + + def test_string_representation(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2001, 6, 19) + interval = DateInterval(start, end) + + assert str(interval) == "[2000-01-01, 2001-06-19]" + + def test_length(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2000, 2, 10) + interval = DateInterval(start, end) + + assert len(interval) == 41 + + def test_calendar(self) -> None: + calendar = CalendarSystem.julian + start: LocalDate = LocalDate(2000, 1, 1, calendar) + end: LocalDate = LocalDate(2000, 2, 10, calendar) + interval = DateInterval(start, end) + assert interval.calendar == calendar + + @pytest.mark.parametrize( + ("candidate_text", "expected"), + [ + pytest.param("1999-12-31", False, id="Before start"), + pytest.param("2000-01-01", True, id="On start"), + pytest.param("2005-06-06", True, id="In middle"), + pytest.param("2014-06-30", True, id="On end"), + pytest.param("2014-07-01", False, id="After end"), + ], + ) + def test_contains(self, candidate_text: str, expected: bool) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2014, 6, 30) + candidate = LocalDatePattern.iso.parse(candidate_text).value + interval = DateInterval(start, end) + assert interval.contains(candidate) is expected + # Different to Noda Time: + assert (candidate in interval) is expected + + def test_contains_different_calendar(self) -> None: + start: LocalDate = LocalDate(2000, 1, 1) + end: LocalDate = LocalDate(2014, 6, 30) + interval = DateInterval(start, end) + candidate = LocalDate(2000, 1, 1, self.JULIAN_CALENDAR) + with pytest.raises(ValueError): + interval.contains(candidate) + # Different to Noda Time: + with pytest.raises(ValueError): + candidate in interval + + # TODO: def test_deconstruction(self) -> None: + + def test_contains_null_interval_throws(self) -> None: + start: LocalDate = LocalDate(2017, 11, 6) + end: LocalDate = LocalDate(2017, 11, 10) + value = DateInterval(start, end) + + with pytest.raises(TypeError): # ArgumentNullException in Noda Time + value.contains(None) # type: ignore[call-overload] + + def test_contains_interval_within_another_calendar_throws(self) -> None: + value = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.gregorian), + LocalDate(2017, 11, 10, CalendarSystem.gregorian), + ) + + other = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.coptic), + LocalDate(2017, 11, 10, CalendarSystem.coptic), + ) + + with pytest.raises(ValueError): + value.contains(other) + # Different to Noda Time: + with pytest.raises(ValueError): + other in value + + @pytest.mark.parametrize( + ("first_interval", "second_interval", "expected_result"), + [ + ("2014-03-07,2014-03-07", "2014-03-07,2014-03-07", True), + ("2014-03-07,2014-03-10", "2015-01-01,2015-04-01", False), + ("2015-01-01,2015-04-01", "2014-03-07,2014-03-10", False), + ("2014-03-07,2014-03-31", "2014-03-07,2014-03-15", True), + ("2014-03-07,2014-03-31", "2014-03-10,2014-03-31", True), + ("2014-03-07,2014-03-31", "2014-03-10,2014-03-15", True), + ("2014-03-07,2014-03-31", "2014-03-05,2014-03-09", False), + ("2014-03-07,2014-03-31", "2014-03-20,2014-04-07", False), + ("2014-11-01,2014-11-30", "2014-01-01,2014-12-31", False), + ], + ) + def test_contains_interval_overload(self, first_interval: str, second_interval: str, expected_result: bool) -> None: + value = self.__parse_interval(first_interval) + other = self.__parse_interval(second_interval) + assert value.contains(other) is expected_result + # Different to Noda Time: + assert (other in value) is expected_result + + def test_intersection_null_interval_throws(self) -> None: + value = DateInterval(LocalDate._ctor(days_since_epoch=100), LocalDate._ctor(days_since_epoch=200)) + with pytest.raises(TypeError): # ArgumentNullException in Noda Time + value.intersection(None) # type: ignore[arg-type] + # Different to Noda Time: + with pytest.raises(TypeError): + None & value # type: ignore[operator] + + def test_intersection_interval_in_different_calendar_throws(self) -> None: + value = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.gregorian), + LocalDate(2017, 11, 10, CalendarSystem.gregorian), + ) + other = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.coptic), + LocalDate(2017, 11, 10, CalendarSystem.coptic), + ) + with pytest.raises(ValueError): # ArgumentException in Noda Time + value.intersection(other) + # Different to Noda Time: + with pytest.raises(ValueError): + value & other + + @pytest.mark.parametrize( + ("first_interval", "second_interval", "expected_interval"), + [ + ("2014-03-07,2014-03-07", "2014-03-07,2014-03-07", "2014-03-07,2014-03-07"), + ("2014-03-07,2014-03-10", "2015-01-01,2015-04-01", None), + ("2015-01-01,2015-04-01", "2014-03-07,2014-03-10", None), + ("2014-03-07,2014-03-31", "2014-03-07,2014-03-15", "2014-03-07,2014-03-15"), + ("2014-03-07,2014-03-31", "2014-03-10,2014-03-31", "2014-03-10,2014-03-31"), + ("2014-03-07,2014-03-31", "2014-03-10,2014-03-15", "2014-03-10,2014-03-15"), + ("2014-03-07,2014-03-31", "2014-03-05,2014-03-09", "2014-03-07,2014-03-09"), + ("2014-03-07,2014-03-31", "2014-03-20,2014-04-07", "2014-03-20,2014-03-31"), + ("2014-11-01,2014-11-30", "2014-01-01,2014-12-31", "2014-11-01,2014-11-30"), + ], + ) + def test_intersection(self, first_interval: str, second_interval: str, expected_interval: str | None) -> None: + value = self.__parse_interval(first_interval) + other = self.__parse_interval(second_interval) + expected_result = self.__parse_interval_or_none(expected_interval) + assert value.intersection(other) == expected_result + # Different to Noda Time: + assert (value & other) == expected_result + + def test_union_null_interval_throws(self) -> None: + value = DateInterval(LocalDate._ctor(days_since_epoch=100), LocalDate._ctor(days_since_epoch=200)) + with pytest.raises(TypeError): + value.union(None) # type: ignore[arg-type] + # Different to Noda Time: + with pytest.raises(TypeError): + value | None # type: ignore[operator] + + def test_union_different_calendar_throws(self) -> None: + value = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.gregorian), + LocalDate(2017, 11, 10, CalendarSystem.gregorian), + ) + other = DateInterval( + LocalDate(2017, 11, 6, CalendarSystem.coptic), + LocalDate(2017, 11, 10, CalendarSystem.coptic), + ) + + with pytest.raises(ValueError): # ArgumentException in Noda Time + value.union(other) + # Different to Noda Time: + with pytest.raises(ValueError): + other | value + + @pytest.mark.parametrize( + ("first", "second", "expected"), + [ + pytest.param("2014-03-07,2014-03-20", "2015-03-07,2015-03-20", None, id="Disjointed intervals"), + pytest.param( + "2014-03-07,2014-03-20", "2014-03-21,2014-03-30", "2014-03-07,2014-03-30", id="Abutting intervals" + ), + pytest.param( + "2014-03-07,2014-03-20", "2014-03-07,2014-03-20", "2014-03-07,2014-03-20", id="Equal intervals" + ), + pytest.param( + "2014-03-07,2014-03-20", "2014-03-15,2014-03-23", "2014-03-07,2014-03-23", id="Overlapping intervals" + ), + pytest.param( + "2014-03-07,2014-03-20", + "2014-03-10,2014-03-15", + "2014-03-07,2014-03-20", + id="Interval completely contained in another", + ), + ], + ) + def test_union(self, first: str, second: str, expected: str | None) -> None: + first_interval = self.__parse_interval(first) + second_interval = self.__parse_interval(second) + expected_result = self.__parse_interval_or_none(expected) + + assert first_interval.union(second_interval) == expected_result + assert second_interval.union(first_interval) == expected_result + # Different to Noda Time: + assert (first_interval | second_interval) == expected_result + assert (second_interval | first_interval) == expected_result + + @pytest.mark.parametrize( + ("interval_text", "expected_date_texts"), + [ + pytest.param("2018-05-04,2018-05-06", ["2018-05-04", "2018-05-05", "2018-05-06"], id="Multi-day"), + pytest.param("2018-05-04,2018-05-04", ["2018-05-04"], id="Single date"), + pytest.param("9999-12-29,9999-12-31", ["9999-12-29", "9999-12-30", "9999-12-31"], id="Max dates"), + ], + ) + def test_iteration(self, interval_text: str, expected_date_texts: list[str]) -> None: + interval = self.__parse_interval(interval_text) + expected = [LocalDatePattern.iso.parse(x).value for x in expected_date_texts] + actual = list(interval) + assert actual == expected + + def __parse_interval_or_none(self, textual_interval: str | None) -> DateInterval | None: + if textual_interval is None: + return None + return self.__parse_interval(textual_interval) + + def __parse_interval(self, textual_interval: str) -> DateInterval: + parts = textual_interval.split(",") + start = LocalDatePattern.iso.parse(parts[0]).value + end = LocalDatePattern.iso.parse(parts[1]).value + return DateInterval(start, end) diff --git a/tests/test_local_date.py b/tests/test_local_date.py index 18bf1a0..65d38a8 100644 --- a/tests/test_local_date.py +++ b/tests/test_local_date.py @@ -2,16 +2,32 @@ # Use of this source code is governed by the Apache License 2.0, # as found in the LICENSE.txt file. import datetime +from typing import cast import pytest -from pyoda_time import CalendarSystem, IsoDayOfWeek, LocalDate, LocalDateTime, LocalTime -from pyoda_time.calendars import Era +from pyoda_time import ( + CalendarSystem, + DateAdjusters, + Instant, + IsoDayOfWeek, + LocalDate, + LocalDateTime, + LocalTime, + Offset, + OffsetDate, + Period, + PyodaConstants, +) +from pyoda_time.calendars import Era, IslamicEpoch, IslamicLeapYearPattern from pyoda_time.calendars._gregorian_year_month_day_calculator import _GregorianYearMonthDayCalculator +from pyoda_time.utility._csharp_compatibility import _CsharpConstants, _towards_zero_division +from tests.helpers import assert_overflow class TestLocalDate: def test_default_constructor(self) -> None: + """Using the default constructor is equivalent to January 1st 1970, UTC, ISO calendar.""" actual = LocalDate() assert actual == LocalDate(year=1, month=1, day=1) @@ -63,6 +79,165 @@ def test_deconstruction(self) -> None: # TODO: def test_deconstruction_including_calendar(self) -> None: +class TestLocalDateBasicProperties: + def test_epoch_properties(self) -> None: + date: LocalDate = PyodaConstants.UNIX_EPOCH.in_utc().date + assert date.year == 1970 + assert date.year_of_era == 1970 + assert date.day == 1 + assert date.day_of_week == IsoDayOfWeek.THURSDAY + assert date.day_of_year == 1 + assert date.month == 1 + + def test_arbitrary_date_properties(self) -> None: + # This is necessarily different to Noda Time, because it relies on BCL. + from datetime import datetime + + stdlib_date = datetime(2011, 3, 5, 0, 0, 0) + stdlib_epoch = datetime(1970, 1, 1, 0, 0, 0) + stdlib_seconds = (stdlib_date - stdlib_epoch).total_seconds() + stdlib_days = _towards_zero_division(stdlib_seconds, PyodaConstants.SECONDS_PER_DAY) + date = LocalDate._ctor(days_since_epoch=stdlib_days, calendar=CalendarSystem.iso) + assert date.year == 2011 + assert date.year_of_era == 2011 + assert date.day == 5 + assert date.day_of_week == IsoDayOfWeek.SATURDAY + assert date.day_of_year == 64 + assert date.month == 3 + + # TODO: def test_day_of_week_around_epoch(self) -> None: [requires bcl] + + +class TestLocalDateComparison: + def test_equals_equal_values(self) -> None: + calendar: CalendarSystem = CalendarSystem.julian + date1: LocalDate = LocalDate(2011, 1, 2, calendar) + date2: LocalDate = LocalDate(2011, 1, 2, calendar) + # In Noda Time this also tests Object.Equals() and IEquatable.Equals() + assert date1 == date2 + assert hash(date1) == hash(date2) + assert not (date1 != date2) + + def test_equals_different_dates(self) -> None: + calendar: CalendarSystem = CalendarSystem.julian + date1: LocalDate = LocalDate(2011, 1, 2, calendar) + date2: LocalDate = LocalDate(2011, 1, 3, calendar) + # In Noda Time this also tests Object.Equals() and IEquatable.Equals() + assert date1 != date2 + assert hash(date1) != hash(date2) + assert not (date1 == date2) + + def test_equals_different_calendars(self) -> None: + calendar: CalendarSystem = CalendarSystem.julian + date1: LocalDate = LocalDate(2011, 1, 2, calendar) + date2: LocalDate = LocalDate(2011, 1, 2, CalendarSystem.iso) + # In Noda Time this also tests Object.Equals() and IEquatable.Equals() + assert date1 != date2 + assert hash(date1) != hash(date2) + assert not (date1 == date2) + + def test_equals_different_to_null(self) -> None: + date: LocalDate = LocalDate(2011, 1, 2) + assert not (date == None) # noqa: E711 + + def test_equals_different_to_other_type(self) -> None: + date: LocalDate = LocalDate(2011, 1, 2) + assert not (date == Instant.from_unix_time_ticks(0)) + + def test_comparison_operators_same_calendar(self) -> None: + date1: LocalDate = LocalDate(2011, 1, 2) + date2: LocalDate = LocalDate(2011, 1, 2) + date3: LocalDate = LocalDate(2011, 1, 5) + + assert not date1 < date2 + assert date1 < date3 + assert not date2 < date1 + assert not date3 < date1 + + assert date1 <= date2 + assert date1 <= date3 + assert date2 <= date1 + assert not date3 <= date1 + + assert not date1 > date2 + assert not date1 > date3 + assert not date2 > date1 + assert date3 > date1 + + assert date1 >= date2 + assert not date1 >= date3 + assert date2 >= date1 + assert date3 >= date1 + + def test_comparison_operators_different_calendars_throws(self) -> None: + date1: LocalDate = LocalDate(2011, 1, 2) + date2: LocalDate = LocalDate(2011, 1, 2, CalendarSystem.julian) + + with pytest.raises(ValueError): # TODO: ArgumentException + _ = date1 < date2 + with pytest.raises(ValueError): # TODO: ArgumentException + _ = date1 <= date2 + with pytest.raises(ValueError): # TODO: ArgumentException + _ = date1 > date2 + with pytest.raises(ValueError): # TODO: ArgumentException + _ = date1 >= date2 + + def test_compare_to_same_calendar(self) -> None: + date1: LocalDate = LocalDate(2011, 1, 2) + date2: LocalDate = LocalDate(2011, 1, 2) + date3: LocalDate = LocalDate(2011, 1, 5) + + assert date1.compare_to(date2) == 0 + assert date1.compare_to(date3) < 0 + assert date3.compare_to(date2) > 0 + + def test_compare_to_different_calendars_throws(self) -> None: + islamic: CalendarSystem = CalendarSystem.get_islamic_calendar( + IslamicLeapYearPattern.BASE15, IslamicEpoch.ASTRONOMICAL + ) + date1: LocalDate = LocalDate(2011, 1, 2) + date2: LocalDate = LocalDate(1500, 1, 1, islamic) + + with pytest.raises(ValueError): # TODO: ArgumentException + date1.compare_to(date2) + # In Noda Time, IComparable.compare_to() is tested here... + + @pytest.mark.skip("Not applicable to Python") + def test_icomparable_compare_to_same_calendar(self) -> None: + pass # pragma: no cover + + def test_icomparable_compare_to_null_positive(self) -> None: + """IComparable.CompareTo returns a positive number for a null input.""" + comparable = LocalDate(2012, 3, 5) + result = comparable.compare_to(None) + assert result > 0 + + def test_icomparable_compare_to_wrong_type_argument_exception(self) -> None: + """IComparable.CompareTo throws an ArgumentException for non-null arguments that are not a LocalDate.""" + instance = LocalDate(2012, 3, 5) + arg = LocalDateTime(2012, 3, 6, 15, 42) + with pytest.raises(TypeError): + instance.compare_to(arg) # type: ignore[arg-type] + + def test_min_max_different_calendars_throws(self) -> None: + date1: LocalDate = LocalDate(2011, 1, 2) + date2: LocalDate = LocalDate(1500, 1, 1, CalendarSystem.julian) + + with pytest.raises(ValueError): + LocalDate.max(date1, date2) + with pytest.raises(ValueError): + LocalDate.min(date1, date2) + + def test_min_max_same_calendar(self) -> None: + date1: LocalDate = LocalDate(1500, 1, 2, CalendarSystem.julian) + date2: LocalDate = LocalDate(1500, 1, 1, CalendarSystem.julian) + + assert LocalDate.max(date1, date2) == date1 + assert LocalDate.max(date2, date1) == date1 + assert LocalDate.min(date1, date2) == date2 + assert LocalDate.min(date2, date1) == date2 + + class TestLocalDateConstruction: @pytest.mark.parametrize( "year", @@ -196,6 +371,44 @@ def test_from_year_month_week_and_day( class TestLocalDateConversion: + def test_at_midnight(self) -> None: + date: LocalDate = LocalDate(2011, 6, 29) + expected: LocalDateTime = LocalDateTime(2011, 6, 29, 0, 0, 0) + assert date.at_midnight() == expected + + def test_with_calendar(self) -> None: + iso_epoch: LocalDate = LocalDate(1970, 1, 1) + julian_epoch = iso_epoch.with_calendar(CalendarSystem.julian) + assert julian_epoch.year == 1969 + assert julian_epoch.month == 12 + assert julian_epoch.day == 19 + + def test_with_offset(self) -> None: + date = LocalDate(2011, 6, 29) + offset = Offset.from_hours(5) + expected = OffsetDate(date, offset) + assert date.with_offset(offset) == expected + + # TODO: Skipping the following tests as they are not relevant in Python, where `datetime.date` is not a recent + # addition to the stdlib (unlike `DateOnly` in dotnet). + # - ToDateTimeUnspecified + # - ToDateTimeUnspecified_JulianCalendar + # - FromDateTime + # - FromDateTime_WithCalendar + + def test_with_calendar_out_of_range(self) -> None: + start: LocalDate = LocalDate(1, 1, 1) + with pytest.raises(ValueError): + start.with_calendar(CalendarSystem.persian_simple) + + def test_with_calendar_unchanged(self) -> None: + iso_epoch: LocalDate = LocalDate(1970, 1, 1) + unchanged = iso_epoch.with_calendar(CalendarSystem.iso) + assert unchanged.year == iso_epoch.year + assert unchanged.month == iso_epoch.month + assert unchanged.day == iso_epoch.day + assert unchanged.calendar == iso_epoch.calendar + def test_to_date_gregorian(self) -> None: local_date = LocalDate(2011, 8, 5) expected = datetime.date(2011, 8, 5) @@ -228,3 +441,254 @@ def test_from_date(self) -> None: expected = LocalDate(2011, 8, 18) actual = LocalDate.from_date(date) assert actual == expected + + +class TestLocalDatePeriodArithmetic: + def test_addition_with_period(self) -> None: + start = LocalDate(2010, 6, 19) + period = Period.from_months(3) + Period.from_days(10) + expected = LocalDate(2010, 9, 29) + assert start + period == expected + + def test_addition_truncates_on_short_month(self) -> None: + start = LocalDate(2010, 1, 30) + period = Period.from_months(1) + expected = LocalDate(2010, 2, 28) + assert start + period == expected + + def test_addition_with_null_period_throws_argument_null_exception(self) -> None: + date = LocalDate(2010, 1, 1) + with pytest.raises(TypeError): + date + cast(Period, None) + + def test_subtraction_with_period(self) -> None: + start = LocalDate(2010, 9, 29) + period = Period.from_months(3) + Period.from_days(10) + expected = LocalDate(2010, 6, 19) + assert start - period == expected + + def test_subtraction_truncates_on_short_month(self) -> None: + start = LocalDate(2010, 3, 30) + period = Period.from_months(1) + expected = LocalDate(2010, 2, 28) + assert start - period == expected + + def test_subtraction_with_null_period_throws_argument_null_exception(self) -> None: + date = LocalDate(2010, 1, 1) + with pytest.raises(TypeError): + date - cast(Period, None) + + def test_addition_period_with_time(self) -> None: + date = LocalDate(2010, 1, 1) + period = Period.from_hours(1) + # Use method not operator here to form a valid statement + with pytest.raises(ValueError): + LocalDate.add(date, period) + + def test_subtraction_period_with_time(self) -> None: + date = LocalDate(2010, 1, 1) + period = Period.from_hours(1) + # Use method not operator here to form a valid statement + with pytest.raises(ValueError): + LocalDate.subtract(date, period) + + def test_period_addition_method_equivalents(self) -> None: + start = LocalDate(2010, 6, 19) + period = Period.from_months(3) + Period.from_days(10) + assert LocalDate.add(start, period) == start + period + assert start.plus(period) == start + period + + def test_period_subtraction_method_equivalents(self) -> None: + start = LocalDate(2010, 6, 19) + period = Period.from_months(3) + Period.from_days(10) + end = start + period + assert LocalDate.subtract(start, period) == start - period + assert start.minus(period) == start - period + assert end - start == period + assert LocalDate.subtract(end, start) == period + assert end.minus(start) == period + + @pytest.mark.parametrize( + ("year", "month", "day", "months_to_add"), + [ + (9999, 12, 31, 1), + (9999, 12, 1, 1), + (9999, 11, 30, 2), + (9999, 11, 1, 2), + (9998, 12, 31, 13), + (9998, 12, 1, 13), + (-9998, 1, 1, -1), + (-9998, 2, 1, -2), + (-9997, 1, 1, -13), + ], + ) + def test_plus_months_overflow(self, year: int, month: int, day: int, months_to_add: int) -> None: + start = LocalDate(year, month, day) + with pytest.raises(OverflowError): + start.plus_months(months_to_add) + + +class TestLocalDatePseudomutators: + def test_plus_year_simple(self) -> None: + start = LocalDate(2011, 6, 26) + expected = LocalDate(2016, 6, 26) + assert start.plus_years(5) == expected + + expected = LocalDate(2006, 6, 26) + assert start.plus_years(-5) == expected + + def test_plus_year_leap_to_non_leap(self) -> None: + start = LocalDate(2012, 2, 29) + expected = LocalDate(2013, 2, 28) + assert start.plus_years(1) == expected + + expected = LocalDate(2011, 2, 28) + assert start.plus_years(-1) == expected + + def test_plus_year_leap_to_leap(self) -> None: + start = LocalDate(2012, 2, 29) + expected = LocalDate(2016, 2, 29) + assert start.plus_years(4) == expected + + def test_plus_month_simple(self) -> None: + start = LocalDate(2012, 4, 15) + expected = LocalDate(2012, 8, 15) + assert start.plus_months(4) == expected + + def test_plus_month_changing_year(self) -> None: + start = LocalDate(2012, 10, 15) + expected = LocalDate(2013, 2, 15) + assert start.plus_months(4) == expected + + def test_plus_month_truncation(self) -> None: + start = LocalDate(2011, 1, 30) + expected = LocalDate(2011, 2, 28) + assert start.plus_months(1) == expected + + def test_plus_days_same_month(self) -> None: + start = LocalDate(2011, 1, 15) + expected = LocalDate(2011, 1, 23) + assert start.plus_days(8) == expected + + expected = LocalDate(2011, 1, 7) + assert start.plus_days(-8) == expected + + def test_plus_days_month_boundary(self) -> None: + start = LocalDate(2011, 1, 26) + expected = LocalDate(2011, 2, 3) + assert start.plus_days(8) == expected + + # Round-trip back across the boundary + assert start.plus_days(8).plus_days(-8) == start + + def test_plus_days_year_boundary(self) -> None: + start = LocalDate(2011, 12, 26) + expected = LocalDate(2012, 1, 3) + assert start.plus_days(8) == expected + + # Round-trip back across the boundary + assert start.plus_days(8).plus_days(-8) == start + + def test_plus_days_end_of_february_in_leap_year(self) -> None: + start = LocalDate(2012, 2, 26) + expected = LocalDate(2012, 3, 5) + assert start.plus_days(8) == expected + # Round-trip back across boundary + assert start.plus_days(8).plus_days(-8) == start + + def test_plus_days_end_of_february_not_in_leap_year(self) -> None: + start = LocalDate(2011, 2, 26) + expected = LocalDate(2011, 3, 6) + assert start.plus_days(8) == expected + + # Round-trip back across boundary + assert start.plus_days(8).plus_days(-8) == start + + def test_plus_days_large_value(self) -> None: + start = LocalDate(2013, 2, 26) + expected = LocalDate(2015, 2, 26) + assert start.plus_days(365 * 2) == expected + + def test_plus_weeks_simple(self) -> None: + start = LocalDate(2011, 4, 2) + expected_forward = LocalDate(2011, 4, 23) + expected_backward = LocalDate(2011, 3, 12) + assert start.plus_weeks(3) == expected_forward + assert start.plus_weeks(-3) == expected_backward + + @pytest.mark.parametrize( + ("year", "month", "day", "days"), + [ + (-9998, 1, 1, -1), + (-9996, 1, 1, -1000), + (9999, 12, 31, 1), + (9997, 12, 31, 1000), + (2000, 1, 1, _CsharpConstants.INT_MAX_VALUE), + (1, 1, 1, _CsharpConstants.INT_MIN_VALUE), + ], + ) + def test_plus_days_out_of_range(self, year: int, month: int, day: int, days: int) -> None: + start = LocalDate(year, month, day) + assert_overflow(start.plus_days, days) + + @pytest.mark.parametrize( + ("day_of_month", "target_day_of_week", "expected_result"), + [ + (10, IsoDayOfWeek.WEDNESDAY, 16), + (10, IsoDayOfWeek.FRIDAY, 11), + (10, IsoDayOfWeek.THURSDAY, 17), + (11, IsoDayOfWeek.WEDNESDAY, 16), + (11, IsoDayOfWeek.THURSDAY, 17), + (11, IsoDayOfWeek.FRIDAY, 18), + (11, IsoDayOfWeek.SATURDAY, 12), + (11, IsoDayOfWeek.SUNDAY, 13), + (12, IsoDayOfWeek.FRIDAY, 18), + (13, IsoDayOfWeek.FRIDAY, 18), + ], + ) + def test_next(self, day_of_month: int, target_day_of_week: IsoDayOfWeek, expected_result: int) -> None: + """Each test case gives a day-of-month in November 2011 and a target "next day of week"; the result is the next + day-of-month in November 2011 with that target day. + + The tests are picked somewhat arbitrarily... + """ + start = LocalDate(2011, 11, day_of_month) + target = start.next(target_day_of_week) + assert target.year == 2011 + assert target.month == 11 + assert target.day == expected_result + + # TODO: Skipping `Next_InvalidArgument` as python enums do not work like C# enums. + + @pytest.mark.parametrize( + ("day_of_month", "target_day_of_week", "expected_result"), + [ + (10, IsoDayOfWeek.WEDNESDAY, 9), + (10, IsoDayOfWeek.FRIDAY, 4), + (10, IsoDayOfWeek.THURSDAY, 3), + (11, IsoDayOfWeek.WEDNESDAY, 9), + (11, IsoDayOfWeek.THURSDAY, 10), + (11, IsoDayOfWeek.FRIDAY, 4), + (11, IsoDayOfWeek.SATURDAY, 5), + (11, IsoDayOfWeek.SUNDAY, 6), + (12, IsoDayOfWeek.FRIDAY, 11), + (13, IsoDayOfWeek.FRIDAY, 11), + ], + ) + def test_previous(self, day_of_month: int, target_day_of_week: IsoDayOfWeek, expected_result: int) -> None: + """Each test case gives a day-of-month in November 2011 and a target "next day of week"; the result is the next + day-of-month in November 2011 with that target day.""" + start = LocalDate(2011, 11, day_of_month) + target = start.previous(target_day_of_week) + assert target.year == 2011 + assert target.month == 11 + assert target.day == expected_result + + # TODO: Skipping `Previous_InvalidArgument` because python enums do not work like C# enums + + # No tests for non-ISO-day-of-week calendars as we don't have any yet. + + def test_with(self) -> None: + start = LocalDate(2014, 6, 27) + expected = LocalDate(2014, 6, 30) + assert start.with_(DateAdjusters.end_of_month) == expected diff --git a/tests/test_offset_date.py b/tests/test_offset_date.py new file mode 100644 index 0000000..26755c3 --- /dev/null +++ b/tests/test_offset_date.py @@ -0,0 +1,103 @@ +# 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, LocalDate, LocalTime, Offset, OffsetDate + +from . import helpers +from .test_offset_time import get_class_properties + + +class TestOffsetDate: + @pytest.mark.parametrize( + "property_name", [name for name in get_class_properties(OffsetDate) if name in get_class_properties(LocalDate)] + ) + def test_local_date_properties(self, property_name: str) -> None: + local = LocalDate(2012, 6, 19, CalendarSystem.julian) + offset = Offset.from_hours(5) + + od = OffsetDate(local, offset) + + actual = getattr(od, property_name) + expected = getattr(local, property_name) + + assert actual == expected + + def test_component_properties(self) -> None: + date = LocalDate(2012, 6, 19, CalendarSystem.julian) + offset = Offset.from_hours(5) + + offset_date = OffsetDate(date, offset) + assert offset_date.offset == offset + assert offset_date.date == date + + def test_equality(self) -> None: + date1 = LocalDate(2012, 10, 6) + date2 = LocalDate(2012, 9, 5) + offset1 = Offset.from_hours(1) + offset2 = Offset.from_hours(2) + + equal1 = OffsetDate(date1, offset1) + equal2 = OffsetDate(date1, offset1) + unequal_by_offset = OffsetDate(date1, offset2) + unequal_by_local = OffsetDate(date2, offset1) + + helpers.test_equals_struct(equal1, equal2, unequal_by_offset) + helpers.test_equals_struct(equal1, equal2, unequal_by_local) + + helpers.test_operator_equality(equal1, equal2, unequal_by_offset) + helpers.test_operator_equality(equal1, equal2, unequal_by_local) + + def test_at(self) -> None: + date = LocalDate(2012, 6, 19, CalendarSystem.julian) + offset = Offset.from_hours(5) + time = LocalTime(14, 15, 12).plus_nanoseconds(123456789) + + assert date.at(time).with_offset(offset) == OffsetDate(date, offset).at(time) + + def test_with_offset(self) -> None: + date = LocalDate(2012, 6, 19) + initial = OffsetDate(date, Offset.from_hours(2)) + actual = initial.with_offset(Offset.from_hours(5)) + expected = OffsetDate(date, Offset.from_hours(5)) + assert actual == expected + + def test_with_calendar(self) -> None: + julian_date = LocalDate(2012, 6, 19, CalendarSystem.julian) + iso_date = julian_date.with_calendar(CalendarSystem.iso) + offset = Offset.from_hours(5) + actual = OffsetDate(julian_date, offset).with_calendar(CalendarSystem.iso) + expected = OffsetDate(iso_date, offset) + assert actual == expected + + def test_with_adjuster(self) -> None: + initial = OffsetDate(LocalDate(2016, 6, 19), Offset.from_hours(-5)) + actual = initial.with_(DateAdjusters.start_of_month) + expected = OffsetDate(LocalDate(2016, 6, 1), Offset.from_hours(-5)) + assert actual == expected + + @pytest.mark.xfail(reason="requires OffsetDatePattern") + def test_to_string_with_format(self) -> None: + date = LocalDate(2012, 10, 6) + offset = Offset.from_hours(1) + offset_date = OffsetDate(date, offset) + assert f"{offset_date}:uuuu/MM/dd o<-HH>" == "2012/10/06 01" + + # TODO: def test_to_string_with_null_format(self) -> None: + # TODO: def test_to_string_no_format(self) -> None: + + @pytest.mark.xfail(reason="Deconstruct not implemented") + def test_deconstruction(self) -> None: + date = LocalDate(2015, 3, 28) + offset = Offset.from_hours(-2) + offset_date = OffsetDate(date, offset) + + actual_date, actual_offset = offset_date # type: ignore[misc] + + assert actual_date == date # type: ignore[has-type] # pragma: no cover + assert actual_offset == offset # type: ignore[has-type] # pragma: no cover + + # TODO: test_xml_serialization_iso() -> None: + # TODO: test_xml_serialization_bce() -> None: + # TODO: test_xml_serialization_non_iso() -> None: