diff --git a/pyoda_time/_offset.py b/pyoda_time/_offset.py index 8d9c262..429ecd8 100644 --- a/pyoda_time/_offset.py +++ b/pyoda_time/_offset.py @@ -416,8 +416,8 @@ def from_timedelta(cls, timedelta: datetime.timedelta) -> Offset: """Converts the given ``timedelta`` to an offset, with fractional seconds truncated. :param timedelta: The timedelta to convert - :returns: An offset for the same time as the given timedelta. :exception ValueError: The given timedelta falls - outside the range of +/- 18 hours. + :returns: An offset for the same time as the given timedelta. + :raises ValueError: The given timedelta falls outside the range of +/- 18 hours. """ # TODO: Consider introducing a "from_microseconds" constructor? diff --git a/pyoda_time/_offset_date_time.py b/pyoda_time/_offset_date_time.py index 8f0263a..6d7802b 100644 --- a/pyoda_time/_offset_date_time.py +++ b/pyoda_time/_offset_date_time.py @@ -4,26 +4,50 @@ from __future__ import annotations +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, overload +from ._calendar_system import CalendarSystem from ._duration import Duration +from ._local_date_time import LocalDateTime +from ._offset import Offset from ._pyoda_constants import PyodaConstants +from ._zoned_date_time import ZonedDateTime +from .utility._hash_code_helper import _hash_code_helper +from .utility._preconditions import _Preconditions if TYPE_CHECKING: from collections.abc import Callable - from . import IsoDayOfWeek, LocalDate, LocalDateTime, LocalTime, OffsetTime - from ._calendar_system import CalendarSystem + from . import DateTimeZone, IsoDayOfWeek, LocalDate, LocalTime, OffsetDate, OffsetTime from ._instant import Instant - from ._offset import Offset from ._year_month_day import _YearMonthDay + from .calendars import Era __all__ = ["OffsetDateTime"] class OffsetDateTime: - def __init__(self, local_date_time: LocalDateTime, offset: Offset) -> None: + """A local date and time in a particular calendar system, combined with an offset from UTC. + + This is broadly similar to an aware ``datetime`` with a fixed-offset ``timezone``. + + Equality is defined in a component-wise fashion: two values are the same if they represent equal date/time values + (including being in the same calendar) and equal offsets from UTC. + + Ordering between offset date/time values has no natural definition; see ``comparer`` for built-in comparers. + + A value of this type unambiguously represents both a local time and an instant on the timeline, + but does not have a well-defined time zone. This means you cannot reliably know what the local + time would be five minutes later, for example. While this doesn't sound terribly useful, it's very common + in text representations. + + The default value of this type is 0001-01-01T00:00:00Z (midnight on January 1st, 1 C.E. with a UTC offset of 0) in + the ISO calendar. + """ + + def __init__(self, local_date_time: LocalDateTime = LocalDateTime(), offset: Offset = Offset()) -> None: from . import OffsetTime self.__local_date = local_date_time.date @@ -123,6 +147,22 @@ def day_of_week(self) -> IsoDayOfWeek: """ return self.__local_date.day_of_week + @property + def year_of_era(self) -> int: + """Gets the year of this offset date and time within the era. + + :return: The year of this offset date and time within the era. + """ + return self.__local_date.year_of_era + + @property + def era(self) -> Era: + """Gets the era of this offset date and time. + + :return: The era of this offset date and time. + """ + return self.__local_date.era + @property def day_of_year(self) -> int: """Gets the day of this offset date and time within the year. @@ -139,6 +179,14 @@ def hour(self) -> int: """ return self.__offset_time.hour + @property + def clock_hour_of_half_day(self) -> int: + """Gets the hour of the half-day of this offset date and time, in the range 1 to 12 inclusive. + + :return: The hour of the half-day of this offset date and time, in the range 1 to 12 inclusive. + """ + return self.__offset_time.clock_hour_of_half_day + @property def minute(self) -> int: """Gets the minute of this offset date and time, in the range 0 to 59 inclusive. @@ -155,6 +203,38 @@ def second(self) -> int: """ return self.__offset_time.second + @property + def millisecond(self) -> int: + """Gets the millisecond of this offset date and time within the second, in the range 0 to 999 inclusive. + + :return: The millisecond of this offset date and time within the second, in the range 0 to 999 inclusive. + """ + return self.__offset_time.millisecond + + @property + def tick_of_second(self) -> int: + """Gets the tick of this offset date and time within the second, in the range 0 to 9,999,999 inclusive. + + :return: The tick of this offset date and time within the second, in the range 0 to 9,999,999 inclusive. + """ + return self.__offset_time.tick_of_second + + @property + def tick_of_day(self) -> int: + """Gets the tick of this offset date and time within the day, in the range 0 to 863,999,999,999 inclusive. + + :return: The tick of this offset date and time within the day, in the range 0 to 863,999,999,999 inclusive. + """ + return self.__offset_time.tick_of_day + + @property + def nanosecond_of_second(self) -> int: + """Gets the nanosecond of this offset date and time within the second, in the range 0 to 999,999,999 inclusive. + + :return: The nanosecond of this offset date and time within the second, in the range 0 to 999,999,999 inclusive. + """ + return self.__offset_time.nanosecond_of_second + @property def nanosecond_of_day(self) -> int: """Gets the nanosecond of this offset date and time within the day, in the range 0 to 86,399,999,999,999 @@ -217,6 +297,147 @@ def to_instant(self) -> Instant: return Instant._from_untrusted_duration(self.__to_elapsed_time_since_epoch()) + def __to_elapsed_time_since_epoch(self) -> Duration: + # Equivalent to LocalDateTime.ToLocalInstant().Minus(offset) + days: int = self.__local_date._days_since_epoch + elapsed_time: Duration = Duration._ctor(days=days, nano_of_day=self.nanosecond_of_day)._minus_small_nanoseconds( + self.__offset_time._offset_nanoseconds + ) + return elapsed_time + + def in_fixed_zone(self) -> ZonedDateTime: + """Returns this value as a ``ZonedDateTime``. + + This method returns a ``ZonedDateTime`` with the same local date and time as this value, using a + fixed time zone with the same offset as the offset for this value. + + Note that because the resulting ``ZonedDateTime`` has a fixed time zone, it is generally not useful to + use this result for arithmetic operations, as the zone will not adjust to account for daylight savings. + + :return: A zoned date/time with the same local time and a fixed time zone using the offset from this value. + """ + from pyoda_time import DateTimeZone + + return ZonedDateTime._ctor(offset_date_time=self, zone=DateTimeZone.for_offset(offset=self.offset)) + + def in_zone(self, zone: DateTimeZone) -> ZonedDateTime: + """Returns this value in ths specified time zone. + + This method does not expect the offset in the zone to be the same as for the current value; it simply converts + this value into an ``Instant`` and finds the ``ZonedDateTime`` for that instant in the specified zone. + + :param zone: The time zone of the new value. + :return: The instant represented by this value, in the specified time zone. + """ + _Preconditions._check_not_null(zone, "zone") + return self.to_instant().in_zone(zone=zone) + + def to_aware_datetime(self) -> datetime: + """Returns an aware ``datetime.datetime`` correspdonding to this offset date and time. + + Note that although the returned ``datetime.datetime`` is "aware", the ``tzinfo`` will be an instance of + ``datetime.timezone`` with a fixed utcoffset. It will not be associated with a real-word time zone. + + If the date and time is not on a microsecond boundary (the unit of granularity of ``datetime``) the value will + be truncated towards the start of time. + + ``datetime`` uses the Gregorian calendar by definition, so the value is implicitly converted to the Gregorian + calendar first. The result will be the same instant in time (potentially truncated as described above), but the + values returned by the Year/Month/Day properties of the ``datetime`` may not match the Year/Month/Day properties + of this value. + + :return: A ``datetime.datetime`` with the same local date/time and offset as this. + """ + # Different to Noda Time: + # In .NET, DateTimeOffset has an offset granularity of minutes, which is coarser than that of Noda Time. + # In Python, offsets use datetime.timedelta and therefore could have microsecond granularity. + # If there's a reason for doing so, I'm not sure what it is. + # The point is that we do not need to truncate the offset granularity like Noda Time's `ToDateTimeOffset`. + + gregorian = self.with_calendar(CalendarSystem.gregorian) + + # TODO: Noda Time throws InvalidOperationException if outside the range of DateTimeOffset) + # That's because in dotnet, DateTimeOffset has a min/max offset range of -14 to 14 hours. + # This is less than the range of Noda Time's Offset which is -18 to 18 hours. + # At time of writing, Pyoda Time's Offset shares the same range. + # But python's datetime/date/time may have an offset (timedelta) range of "strictly between" -24 to 24 hours. + + dt_timezone = timezone(offset=timedelta(seconds=self.offset.seconds)) + + return gregorian.local_date_time.to_naive_datetime().replace(tzinfo=dt_timezone) + + @classmethod + def from_aware_datetime(cls, aware_datetime: datetime) -> OffsetDateTime: + """Builds an ``OffsetDateTime`` from an aware ``datetime.datetime``. + + Note that even if the ``tzinfo`` represents a real-world time zone, the ``offset`` will remain fixed. + + :param aware_datetime: The aware ``datetime.datetime`` to convert. + :return: The converted offset date and time. + """ + # Different from Noda Time: + if (tzinfo := aware_datetime.tzinfo) is None: + raise ValueError("aware_datetime must be timezone-aware") + + if not isinstance(delta := tzinfo.utcoffset(aware_datetime), timedelta): + raise ValueError( # pragma: no cover + f"aware_datetime.tzinfo.utcoffset() must be an instance of timedelta; got {delta.__class__.__name__}" + ) + + return OffsetDateTime( + local_date_time=LocalDateTime.from_naive_datetime(aware_datetime.replace(tzinfo=None)), + offset=Offset.from_timedelta(timedelta=delta), + ) + + def with_calendar(self, calendar: CalendarSystem) -> OffsetDateTime: + """Creates a new OffsetDateTime representing the same physical date, time and offset, but in a different + calendar. + + The returned OffsetDateTime 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 and time to. + :return: The converted OffsetDateTime. + """ + new_date = self.date.with_calendar(calendar=calendar) + return OffsetDateTime._ctor(local_date=new_date, offset_time=self.__offset_time) + + def with_date_adjuster(self, adjuster: Callable[[LocalDate], LocalDate]) -> OffsetDateTime: + """Returns this offset date/time, with the given date adjuster applied to it, maintaining the existing time of + day and 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/time. + """ + return OffsetDateTime._ctor( + local_date=self.__local_date.with_date_adjuster(adjuster=adjuster), + offset_time=self.__offset_time, + ) + + def with_time_adjuster(self, adjuster: Callable[[LocalTime], LocalTime]) -> OffsetDateTime: + """Returns this offset date/time, with the given time adjuster applied to it, maintaining the existing date and + offset. + + If the adjuster attempts to construct an invalid time, any exception thrown by that construction attempt will be + propagated through this method. + + :param adjuster: The adjuster to apply. + :return: The adjusted offset date/time. + """ + from ._offset_time import OffsetTime + + new_time = self.time_of_day.with_time_adjuster(adjuster=adjuster) + return OffsetDateTime._ctor( + local_date=self.__local_date, + offset_time=OffsetTime._ctor( + nanosecond_of_day=new_time.nanosecond_of_day, offset_seconds=self.__offset_time._offset_seconds + ), + ) + def with_offset(self, offset: Offset) -> OffsetDateTime: """Creates a new OffsetDateTime representing the instant in time in the same calendar, but with a different offset. The local date and time is adjusted accordingly. @@ -251,53 +472,231 @@ def with_offset(self, offset: Offset) -> OffsetDateTime: ), ) - def __to_elapsed_time_since_epoch(self) -> Duration: - # Equivalent to LocalDateTime.ToLocalInstant().Minus(offset) - days: int = self.__local_date._days_since_epoch - elapsed_time: Duration = Duration._ctor(days=days, nano_of_day=self.nanosecond_of_day)._minus_small_nanoseconds( - self.__offset_time._offset_nanoseconds - ) - return elapsed_time + def to_offset_date(self) -> OffsetDate: + """Constructs a new ``OffsetDate`` from the date and offset of this value, but omitting the time-of-day. - def with_date_adjuster(self, adjuster: Callable[[LocalDate], LocalDate]) -> OffsetDateTime: - """Returns this offset date/time, with the given date adjuster applied to it, maintaining the existing time of - day and offset. + :return: A value representing the date and offset aspects of this value. + """ + from . import OffsetDate - 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. + return OffsetDate(date=self.date, offset=self.offset) - :param adjuster: The adjuster to apply. - :return: The adjusted offset date/time. + def to_offset_time(self) -> OffsetTime: + """Constructs a new ``OffsetTime`` from the time and offset of this value, but omitting the date. + + :return: A value representing the time and offset aspects of this value. """ - return OffsetDateTime._ctor( - local_date=self.__local_date.with_date_adjuster(adjuster=adjuster), - offset_time=self.__offset_time, - ) + return self.__offset_time - def with_time_adjuster(self, adjuster: Callable[[LocalTime], LocalTime]) -> OffsetDateTime: - """Returns this offset date/time, with the given time adjuster applied to it, maintaining the existing date and - offset. + def __hash__(self) -> int: + """Returns a hash code for this offset date and time. - If the adjuster attempts to construct an invalid time, any exception thrown by that construction attempt will be - propagated through this method. + See the type documentation for a description of equality semantics. - :param adjuster: The adjuster to apply. - :return: The adjusted offset date/time. + :return: A hash code for this offset date and time. """ - from ._offset_time import OffsetTime + return _hash_code_helper(self.__local_date, self.__offset_time) - new_time = self.time_of_day.with_time_adjuster(adjuster=adjuster) + def equals(self, other: OffsetDateTime) -> bool: + """Compares two ``OffsetDateTime`` values for equality. + + See the type documentation for a description of equality semantics. + + :param other: The object to compare this date with. + :return: True if the given value is another offset date/time equal to this one; False otherwise. + """ + return isinstance(other, OffsetDateTime) and self == other + + # TODO: Deconstruct [see issue 248] + + # region Formatting + + # TODO: def __repr__(self) -> str: [requires OffsetDateTimePattern] + # TODO: def __format__(self) -> str: [requires OffsetDateTimePattern] + + # endregion + + # region Operators + + @staticmethod + def add(offset_date_time: OffsetDateTime, duration: Duration) -> OffsetDateTime: + """Adds a duration to an offset date and time. + + This is an alternative way of calling ``OffsetDateTime + Duration``. + + :param offset_date_time: The value to add the duration to. + :param duration: The duration to add + :return: A new value with the time advanced by the given duration, in the same calendar system and with the + same offset. + """ + return offset_date_time + duration + + def plus(self, duration: Duration) -> OffsetDateTime: + """Returns the result of adding a duration to this offset date and time. + + This is an alternative way of calling ``OffsetDateTime + Duration``. + + :param duration: The duration to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + duration + + def plus_hours(self, hours: int) -> OffsetDateTime: + """Returns the result of adding a increment of hours to this offset date and time. + + :param hours: The number of hours to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_hours(hours) + + def plus_minutes(self, minutes: int) -> OffsetDateTime: + """Returns the result of adding an increment of minutes to this offset date and time. + + :param minutes: The number of minutes to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_minutes(minutes) + + def plus_seconds(self, seconds: int) -> OffsetDateTime: + """Returns the result of adding an increment of seconds to this offset date and time. + + :param seconds: The number of seconds to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_seconds(seconds) + + def plus_milliseconds(self, milliseconds: int) -> OffsetDateTime: + """Returns the result of adding an increment of milliseconds to this offset date and time. + + :param milliseconds: The number of milliseconds to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_milliseconds(milliseconds) + + def plus_ticks(self, ticks: int) -> OffsetDateTime: + """Returns the result of adding an increment of ticks to this offset date and time. + + :param ticks: The number of ticks to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_ticks(ticks) + + def plus_nanoseconds(self, nanoseconds: int) -> OffsetDateTime: + """Returns the result of adding an increment of nanoseconds to this offset date and time. + + :param nanoseconds: The number of nanoseconds to add + :return: A new ``OffsetDateTime`` representing the result of the addition. + """ + return self + Duration.from_nanoseconds(nanoseconds) + + def __add__(self, duration: Duration) -> OffsetDateTime: + """Returns a new ``OffsetDateTime`` with the time advanced by the given duration. + + The returned value retains the calendar system and offset of the ``OffsetDateTime``. + + :param duration: The duration to add. + :return: A new value with the time advanced by the given duration, in the same calendar system and with the + same offset. + """ return OffsetDateTime._ctor( - local_date=self.__local_date, - offset_time=OffsetTime._ctor( - nanosecond_of_day=new_time.nanosecond_of_day, offset_seconds=self.__offset_time._offset_seconds - ), + instant=self.to_instant() + duration, + offset=self.offset, ) - # region Operators + @staticmethod + @overload + def subtract(offset_date_time: OffsetDateTime, duration: Duration, /) -> OffsetDateTime: + """Subtracts a duration from an offset date and time. + + This is an alternative way of calling ``OffsetDateTime - Duration``. + + :param offset_date_time: The value to subtract the duration from. + :param duration: The duration to subtract. + :return: A new value with the time "rewound" by the given duration, in the same calendar system and with the + same offset. + """ + + @staticmethod + @overload + def subtract(end: OffsetDateTime, start: OffsetDateTime, /) -> Duration: + """Subtracts one offset date and time from another, returning an elapsed duration. + + This is an alternative way of calling ``OffsetDateTime - OffsetDateTime``. + + :param end: The offset date and time value to subtract from; if this is later than ``start`` then the result + will be positive. + :param start: The offset date and time to subtract from ``end``. + :return: The elapsed duration from ``start`` to ``end``. + """ + + @staticmethod + def subtract( + offset_date_time: OffsetDateTime, duration_or_offset: Duration | OffsetDateTime, / + ) -> OffsetDateTime | Duration: + return offset_date_time - duration_or_offset + + @overload + def minus(self, duration: Duration, /) -> OffsetDateTime: + """Returns the result of subtracting a duration from this offset date and time, for a fluent alternative to + ``OffsetDateTime - Duration``. + + :param duration: The duration to subtract + :return: A new ``OffsetDateTime`` representing the result of the subtraction. + """ + + @overload + def minus(self, other: OffsetDateTime, /) -> Duration: + """Returns the result of subtracting another offset date and time from this one, resulting in the elapsed + duration between the two instants represented in the values. + + This is an alternative way of calling ``OffsetDateTime - OffsetDateTime``. + + :param other: The offset date and time to subtract from this one. + :return: The elapsed duration from ``other`` to this value. + """ + + def minus(self, other: Duration | OffsetDateTime, /) -> OffsetDateTime | Duration: + return self - other + + @overload + def __sub__(self, other: Duration) -> OffsetDateTime: + """Returns a new ``OffsetDateTime`` with the duration subtracted. + + The returned value retains the calendar system and offset of the ``OffsetDateTime``. + + :param other: The duration to subtract. + :return: A new value with the time "rewound" by the given duration, in the same calendar system and with the + same offset. + """ + + @overload + def __sub__(self, other: OffsetDateTime) -> Duration: + """Subtracts one ``OffsetDateTime`` from another, resulting in the elapsed time between the two values. + + This is equivalent to ``self.to_instant() - other.to_instant()``; in particular: + + * The two values can use different calendar systems + * The two values can have different UTC offsets + * If the left instance is later than the right instance, the result will be positive. + + :param other: The offset date and time to subtract from this one. + :return: The elapsed duration from ``self`` to ``other``. + """ + + def __sub__(self, other: Duration | OffsetDateTime) -> OffsetDateTime | Duration: + if isinstance(other, Duration): + return OffsetDateTime._ctor( + instant=self.to_instant() - other, + offset=self.offset, + ) + if isinstance(other, OffsetDateTime): + return self.to_instant() - other.to_instant() + return NotImplemented # type: ignore[unreachable] # pragma: no cover def __eq__(self, other: object) -> bool: - """Implements the operator == (equality). See the type documentation for a description of equality semantics. + """Implements the operator == (equality). + + See the type documentation for a description of equality semantics. :param other: The value to compare this offset date/time with. :return: True if the given value is another offset date/time equal to this one; false otherwise.. @@ -306,4 +705,28 @@ def __eq__(self, other: object) -> bool: return NotImplemented return self.__local_date == other.__local_date and self.__offset_time == other.__offset_time + 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/time with. + :return: True if values are not equal to each other, otherwise False. + """ + if isinstance(other, OffsetDateTime): + return not (self == other) + return NotImplemented + + # endregion + + # region Comparers + + # TODO: Comparers + + # endregion + + # region XML serialization + + # TODO: XML Serialization + # endregion diff --git a/tests/test_offset_date_time.py b/tests/test_offset_date_time.py index 6734a8b..7c6b105 100644 --- a/tests/test_offset_date_time.py +++ b/tests/test_offset_date_time.py @@ -1,11 +1,306 @@ # 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 datetime import datetime, timedelta, timezone +from typing import Final -from pyoda_time import DateAdjusters, LocalDateTime, Offset, OffsetDateTime, TimeAdjusters +import pytest + +from pyoda_time import ( + CalendarSystem, + DateAdjusters, + DateTimeZone, + DateTimeZoneProviders, + Duration, + Instant, + LocalDate, + LocalDateTime, + LocalTime, + Offset, + OffsetDate, + OffsetDateTime, + OffsetTime, + PyodaConstants, + TimeAdjusters, + ZonedDateTime, +) +from tests import helpers +from tests.test_offset_time import get_class_properties class TestOffsetDateTime: + @pytest.mark.parametrize( + "property_name", + [name for name in get_class_properties(OffsetDateTime) if name in get_class_properties(LocalDateTime)], + ) + def test_local_date_time_properties(self, property_name: str) -> None: + local = LocalDateTime(2012, 6, 19, 1, 2, 3, calendar=CalendarSystem.julian).plus_nanoseconds(123456789) + offset = Offset.from_hours(5) + odt = OffsetDateTime(local, offset) + + actual = getattr(odt, property_name) + expected = getattr(local, property_name) + + assert actual == expected + + def test_offset_property(self) -> None: + offset = Offset.from_hours(5) + + odt = OffsetDateTime(LocalDateTime(2012, 1, 2, 3, 4), offset) + assert odt.offset == offset + + def test_local_date_time_property(self) -> None: + local = LocalDateTime(2012, 6, 19, 1, 2, 3, calendar=CalendarSystem.julian).plus_nanoseconds(123456789) + offset = Offset.from_hours(5) + + odt = OffsetDateTime(local, offset) + assert odt.local_date_time == local + + def test_to_instant(self) -> None: + instant = Instant.from_utc(2012, 6, 25, 16, 5, 20) + local = LocalDateTime(2012, 6, 25, 21, 35, 20) + offset = Offset.from_hours_and_minutes(5, 30) + + odt = OffsetDateTime(local, offset) + assert odt.to_instant() == instant + + def test_equality(self) -> None: + local1 = LocalDateTime(2012, 10, 6, 1, 2, 3) + local2 = LocalDateTime(2012, 9, 5, 1, 2, 3) + offset1 = Offset.from_hours(1) + offset2 = Offset.from_hours(2) + + equal1 = OffsetDateTime(local1, offset1) + equal2 = OffsetDateTime(local1, offset1) + unequal_by_offset = OffsetDateTime(local1, offset2) + unequal_by_local = OffsetDateTime(local2, 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_to_date_time_offset(self) -> None: + local = LocalDateTime(2012, 10, 6, 1, 2, 3) + offset = Offset.from_hours(1) + odt = OffsetDateTime(local, offset) + + expected = datetime(2012, 10, 6, 1, 2, 3, tzinfo=timezone(offset=timedelta(hours=1))) + actual = odt.to_aware_datetime() + assert actual == expected + + def test_to_date_time_offset_julian_calendar(self) -> None: + local = LocalDateTime(2012, 10, 6, 1, 2, 3, calendar=CalendarSystem.julian) + offset = Offset.from_hours(1) + odt = OffsetDateTime(local, offset) + + # Different to Noda Time: + # Python's datetime does not support different calendar systems (unlike .NET's DateTime[Offset]), + # so there is an implicit conversion to the gregorian calendar in Pyoda Time. + expected = datetime(2012, 10, 19, 1, 2, 3, tzinfo=timezone(offset=timedelta(hours=1))) + actual = odt.to_aware_datetime() + assert actual == expected + + @pytest.mark.parametrize( + ("hours", "minutes", "seconds"), + [ + (0, 30, 20), + (-1, -30, -20), + (0, 30, 55), + (-1, -30, -55), + ], + ) + def test_to_date_time_offset_truncated_offset(self, hours: int, minutes: int, seconds: int) -> None: + # Different to Noda Time: + # The Noda Time test documents that conversions to DateTimeOffset in .NET necessarily truncate the offset, + # because TimeSpan has a resolution of minutes (whereas Noda Time has a resolution of seconds). + # In Python, the converted offsets use timedelta which has microsecond granularity. + # For Pyoda Time, this test is just more coverage for the conversion; no truncation takes place. + + ldt = LocalDateTime(2017, 1, 9, 21, 45, 20) + offset = Offset.from_hours_and_minutes(hours, minutes).plus(Offset.from_seconds(seconds)) + odt = ldt.with_offset(offset) + dto = odt.to_aware_datetime() + + assert dto == datetime( + 2017, 1, 9, 21, 45, 20, tzinfo=timezone(offset=timedelta(hours=hours, minutes=minutes, seconds=seconds)) + ) + + @pytest.mark.xfail( + reason=( + "Python's datetime supports offsets of strictly between -24 and 24 hrs (timedelta), " + "unlike dotnet's DateTimeOffset which supports -14 to 14 (TimeSpan)" + ) + ) + @pytest.mark.parametrize("hours", [-15, 15]) + def test_to_date_time_offset_offset_out_of_range(self, hours: int) -> None: + # TODO: This passes in Noda Time because DateTimeOffset supports an offset (TimeSpan) range of -14 and 14 hours. + # But in Python, datetime may have an offset (timedelta) of strictly between -24 and 24 hours. + # See also: the test directly below this one. + ldt = LocalDateTime(2017, 1, 9, 21, 45, 20) + offset = Offset.from_hours(hours) + odt = ldt.with_offset(offset) + + with pytest.raises(RuntimeError): + odt.to_aware_datetime() + + @pytest.mark.parametrize("hours", [-14, 14]) + def test_to_date_time_offset_offset_edge_of_range(self, hours: int) -> None: + # TODO: This is not the "edge of range" for the min/max timedelta in python. + # See also: The test directly above this one. + ldt = LocalDateTime(2017, 1, 9, 21, 45, 20) + offset = Offset.from_hours(hours) + odt = ldt.with_offset(offset) + assert odt.to_aware_datetime().tzinfo.utcoffset(None).total_seconds() / 60 / 60 == hours # type: ignore[union-attr] + + def test_to_date_time_offset_date_out_of_range(self) -> None: + # One day before 1st January, 1AD (which is DateTime.MinValue) + odt = LocalDate(1, 1, 1).plus_days(-1).at_midnight().with_offset(Offset.from_hours(1)) + with pytest.raises(RuntimeError): + odt.to_aware_datetime() + + @pytest.mark.parametrize("year", [100, 1900, 2900]) + def test_to_date_time_offset_truncate_nanos_toward_start_of_time(self, year: int) -> None: + # Different to Noda Time: + # Pyoda Time truncates to the millisecond, not to the tick. + odt = ( + LocalDateTime(year, 1, 1, 13, 15, 55) + .plus_nanoseconds(PyodaConstants.NANOSECONDS_PER_SECOND - 1) + .with_offset(Offset.from_hours(1)) + ) + expected = datetime(year, 1, 1, 13, 15, 55, tzinfo=timezone(timedelta(hours=1))) + timedelta( + microseconds=PyodaConstants.MICROSECONDS_PER_SECOND - 1 + ) + actual = odt.to_aware_datetime() + assert actual == expected + + def test_from_date_time_offset(self) -> None: + local = LocalDateTime(2012, 10, 6, 1, 2, 3) + offset = Offset.from_hours(1) + expected = OffsetDateTime(local, offset) + + stdlib = datetime(2012, 10, 6, 1, 2, 3, tzinfo=timezone(offset=timedelta(hours=1))) + actual = OffsetDateTime.from_aware_datetime(stdlib) + assert actual == expected + + def test_from_aware_datetime_raises_when_datetime_is_not_aware(self) -> None: + # This test does not exist in Noda Time, because DateTimeOffset always has an offset/timespan. + # In Python, we have one type (datetime) which may or may not be "aware". + dt = datetime(2025, 2, 3) + with pytest.raises(ValueError) as e: + OffsetDateTime.from_aware_datetime(dt) + assert str(e.value) == "aware_datetime must be timezone-aware" + + def test_from_aware_datetime_with_microsecond_granularity_offset(self) -> None: + # This test does not exist in Noda Time, because DateTimeOffset's offset/timespan has minute granularity. + # In Python, datetime.tzinfo's offset can have microsecond granularity. + # (Both Noda Time and Pyoda Time have second granularity) + dt = datetime( + 2025, + 2, + 3, + 3, + 11, + 30, + tzinfo=timezone(offset=timedelta(hours=1, minutes=2, seconds=3, milliseconds=4, microseconds=5)), + ) + actual = OffsetDateTime.from_aware_datetime(dt) + expected = OffsetDateTime( + local_date_time=LocalDateTime(2025, 2, 3, 3, 11, 30), + offset=Offset.from_hours_and_minutes(1, 2) + + Offset.from_seconds(3) + + Offset.from_milliseconds(4) + + Offset.from_ticks(5 * PyodaConstants.TICKS_PER_MICROSECOND), + ) + assert actual == expected + + def test_in_fixed_zone(self) -> None: + offset = Offset.from_hours(5) + local = LocalDateTime(2012, 1, 2, 3, 4) + odt = OffsetDateTime(local, offset) + + zoned = odt.in_fixed_zone() + assert zoned == DateTimeZone.for_offset(offset).at_strictly(local) + + # TODO: [requires OffsetDateTimePattern] + # def test_to_string_whole_hour_offset(self) -> None: + # def test_to_string_part_hour_offset(self) -> None: + # def test_to_string_utc(self) -> None: + # def test_to_string_with_format(self) -> None: + + # TODO: [requires OffsetDateTime.Comparer] + # def test_local_comparer(self) -> None: + # def test_instant_comparer(self) -> None: + + def test_default_constructor(self) -> None: + """Using the default constructor is equivalent to January 1st 1970, midnight, UTC, ISO calendar.""" + actual = OffsetDateTime() + assert actual.local_date_time == LocalDateTime(1, 1, 1, 0, 0) + assert actual.offset == Offset.zero + + def test_subtraction_duration(self) -> None: + # Test all three approaches... not bothering to check a different calendar, + # but we'll use two different offsets. + end = LocalDateTime(2014, 8, 14, 15, 0).with_offset(Offset.from_hours(1)) + duration = Duration.from_hours(8) + Duration.from_minutes(9) + expected = LocalDateTime(2014, 8, 14, 6, 51).with_offset(Offset.from_hours(1)) + assert end - duration == expected + assert end.minus(duration) == expected + assert OffsetDateTime.subtract(end, duration) == expected + + def test_addition_duration(self) -> None: + minutes: Final[int] = 23 + hours: Final[int] = 3 + milliseconds: Final[int] = 40000 + seconds: Final[int] = 321 + nanoseconds: Final[int] = 12345 + ticks: Final[int] = 5432112345 + + # Test all three approaches... not bothering to check a different calendar, + # but we'll use two different offsets. + start = LocalDateTime(2014, 8, 14, 6, 51).with_offset(Offset.from_hours(1)) + duration = Duration.from_hours(8) + Duration.from_minutes(9) + expected = LocalDateTime(2014, 8, 14, 15, 0).with_offset(Offset.from_hours(1)) + + assert start + duration == expected + assert start.plus(duration) == expected + assert OffsetDateTime.add(start, duration) == expected + + assert start.plus_hours(hours) == start + Duration.from_hours(hours) + assert start.plus_hours(-hours) == start + Duration.from_hours(-hours) + + assert start.plus_minutes(minutes) == start + Duration.from_minutes(minutes) + assert start.plus_minutes(-minutes) == start + Duration.from_minutes(-minutes) + + assert start.plus_seconds(seconds) == start + Duration.from_seconds(seconds) + assert start.plus_seconds(-seconds) == start + Duration.from_seconds(-seconds) + + assert start.plus_milliseconds(milliseconds) == start + Duration.from_milliseconds(milliseconds) + assert start.plus_milliseconds(-milliseconds) == start + Duration.from_milliseconds(-milliseconds) + + assert start.plus_ticks(ticks) == start + Duration.from_ticks(ticks) + assert start.plus_ticks(-ticks) == start + Duration.from_ticks(-ticks) + + assert start.plus_nanoseconds(nanoseconds) == start + Duration.from_nanoseconds(nanoseconds) + assert start.plus_nanoseconds(-nanoseconds) == start + Duration.from_nanoseconds(-nanoseconds) + + def test_subtraction_offset_date_time(self) -> None: + # Test all three approaches... not bothering to check a different calendar, + # but we'll use two different offsets. + start = LocalDateTime(2014, 8, 14, 6, 51).with_offset(Offset.from_hours(1)) + end = LocalDateTime(2014, 8, 14, 18, 0).with_offset(Offset.from_hours(4)) + expected = Duration.from_hours(8) + Duration.from_minutes(9) + assert end - start == expected + assert end.minus(start) == expected + assert OffsetDateTime.subtract(end, start) == expected + + # TODO: + # XmlSerialization_Iso + # XmlSerialization_ZeroOffset + # XmlSerialization_NonIso + # XmlSerialization_Invalid + def test_with_offset(self) -> None: morning = LocalDateTime(2014, 1, 31, 9, 30) original = OffsetDateTime(morning, Offset.from_hours(-8)) @@ -29,6 +324,14 @@ def test_with_offset_two_days_forward_and_back(self) -> None: back_again = morning.with_offset(Offset.from_hours(-18)) assert back_again == night + def test_with_calendar(self) -> None: + julian_calendar = CalendarSystem.julian + gregorian_epoch = PyodaConstants.UNIX_EPOCH.with_offset(Offset.zero) + + expected = LocalDate(1969, 12, 19, calendar=julian_calendar).at_midnight().with_offset(Offset.from_hours(0)) + actual = gregorian_epoch.with_calendar(CalendarSystem.julian) + assert actual == expected + def test_with_time_adjuster(self) -> None: offset = Offset.from_hours_and_minutes(2, 30) start = LocalDateTime(2014, 6, 27, 12, 5, 8).plus_nanoseconds(123456789).with_offset(offset) @@ -40,3 +343,33 @@ def test_with_date_adjuster(self) -> None: start = LocalDateTime(2014, 6, 27, 12, 5, 8).plus_nanoseconds(123456789).with_offset(offset) expected = LocalDateTime(2014, 6, 30, 12, 5, 8).plus_nanoseconds(123456789).with_offset(offset) assert start.with_date_adjuster(DateAdjusters.end_of_month) == expected + + def test_in_zone(self) -> None: + offset = Offset.from_hours(-7) + start = LocalDateTime(2017, 10, 31, 18, 12, 0).with_offset(offset) + zone = DateTimeZoneProviders.tzdb["Europe/London"] + zoned = start.in_zone(zone) + + # On October 31st, the UK had already gone back, so the offset is 0. + # Importantly, it's not the offset of the original OffsetDateTime: we're testing + # that InZone *doesn't* require that. + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2017, 11, 1, 1, 12, 0).with_offset(Offset.zero), zone=zone + ) + assert zoned == expected + + # TODO: + # Deconstruction + # MoreGranularDeconstruction + + def test_to_offset_date(self) -> None: + offset = Offset.from_hours_and_minutes(2, 30) + odt = LocalDateTime(2014, 6, 27, 12, 15, 8).plus_nanoseconds(123456789).with_offset(offset) + expected = OffsetDate(LocalDate(2014, 6, 27), offset) + assert odt.to_offset_date() == expected + + def test_to_offset_time(self) -> None: + offset = Offset.from_hours_and_minutes(2, 30) + odt = LocalDateTime(2014, 6, 27, 12, 15, 8).plus_nanoseconds(123456789).with_offset(offset) + expected = OffsetTime(LocalTime(12, 15, 8).plus_nanoseconds(123456789), offset) + assert odt.to_offset_time() == expected