diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0011955..7632b9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.9.1 hooks: - id: ruff args: [--fix] diff --git a/pyoda_time/_calendar_system.py b/pyoda_time/_calendar_system.py index 7a2f14f..c2b777d 100644 --- a/pyoda_time/_calendar_system.py +++ b/pyoda_time/_calendar_system.py @@ -772,5 +772,5 @@ def _for_ordinal_uncached(cls, ordinal: _CalendarOrdinal) -> CalendarSystem: ) case _: raise RuntimeError( - f"CalendarOrdinal '{getattr(ordinal, "name", ordinal)}' not mapped to CalendarSystem." + f"CalendarOrdinal '{getattr(ordinal, 'name', ordinal)}' not mapped to CalendarSystem." ) diff --git a/pyoda_time/_compatibility/_culture_data.py b/pyoda_time/_compatibility/_culture_data.py index ffcaf4a..2e77db7 100644 --- a/pyoda_time/_compatibility/_culture_data.py +++ b/pyoda_time/_compatibility/_culture_data.py @@ -651,9 +651,9 @@ def _get_calendar(self, calendar_id: _CalendarId) -> _CalendarData: if _GlobalizationMode._invariant: return _CalendarData._invariant - assert ( - 0 < calendar_id.value <= _CalendarId.LAST_CALENDAR.value - ), f"Expect calendarId to be in a valid range, not {calendar_id}" + assert 0 < calendar_id.value <= _CalendarId.LAST_CALENDAR.value, ( + f"Expect calendarId to be in a valid range, not {calendar_id}" + ) # arrays are 0 based, calendarIds are 1 based calendar_index = calendar_id.value - 1 diff --git a/pyoda_time/_compatibility/_date_time_format_info.py b/pyoda_time/_compatibility/_date_time_format_info.py index 0d3e536..2c5a69b 100644 --- a/pyoda_time/_compatibility/_date_time_format_info.py +++ b/pyoda_time/_compatibility/_date_time_format_info.py @@ -530,9 +530,9 @@ def __internal_get_genitive_month_names(self, abbreviated: bool) -> Sequence[str self.__m_genitive_abbreviated_month_names = self._culture_data._abbreviated_genitive_month_names( self.calendar._id ) - assert ( - len(self.__m_genitive_abbreviated_month_names) == 13 - ), "Expected 13 abbreviated genitive month names in a year" + assert len(self.__m_genitive_abbreviated_month_names) == 13, ( + "Expected 13 abbreviated genitive month names in a year" + ) return self.__m_genitive_abbreviated_month_names if self.__genitive_month_names is None: diff --git a/pyoda_time/_date_time_zone.py b/pyoda_time/_date_time_zone.py index 4169519..8a8fc6a 100644 --- a/pyoda_time/_date_time_zone.py +++ b/pyoda_time/_date_time_zone.py @@ -6,18 +6,26 @@ import abc import threading -from typing import TYPE_CHECKING, Final, _ProtocolMeta +from typing import TYPE_CHECKING, Final, _ProtocolMeta, overload from ._duration import Duration +from ._instant import Instant from ._offset import Offset +from ._offset_date_time import OffsetDateTime from ._pyoda_constants import PyodaConstants +from ._skipped_time_error import SkippedTimeError +from ._zoned_date_time import ZonedDateTime +from .time_zones import Resolvers, ZoneLocalMappingResolver from .time_zones._i_zone_interval_map import _IZoneIntervalMap from .time_zones._zone_local_mapping import ZoneLocalMapping from .utility._csharp_compatibility import _csharp_modulo, _towards_zero_division from .utility._preconditions import _Preconditions if TYPE_CHECKING: - from ._instant import Instant + from collections.abc import Generator + + from ._interval import Interval + from ._local_date import LocalDate from ._local_date_time import LocalDateTime from ._local_instant import _LocalInstant from .time_zones._zone_interval import ZoneInterval @@ -197,6 +205,92 @@ def map_local(self, local_date_time: LocalDateTime) -> ZoneLocalMapping: # region Conversion between local dates/times and ZonedDateTime + def at_start_of_day(self, date: LocalDate) -> ZonedDateTime: + """Returns the earliest valid `ZonedDateTime` with the given local date. + + If midnight exists unambiguously on the given date, it is returned. + If the given date has an ambiguous start time (e.g. the clocks go back from 1am to midnight) + then the earlier ZonedDateTime is returned. If the given date has no midnight (e.g. the clocks + go forward from midnight to 1am) then the earliest valid value is returned; this will be the instant + of the transition. + + :param date: The local date to map in this time zone. + :raises SkippedTimeError: The entire day was skipped due to a very large time zone transition. (This is + extremely rare.) + :return: The `ZonedDateTime` representing the earliest time in the given date, in this time zone. + """ + midnight: LocalDateTime = date.at_midnight() + mapping = self.map_local(local_date_time=midnight) + match mapping.count: + # Midnight doesn't exist. Maybe we just skip to 1am (or whatever), or maybe the whole day is missed. + case 0: + interval = mapping.late_interval + # Safe to use Start, as it can't extend to the start of time. + offset_date_time = OffsetDateTime._ctor( + instant=interval.start, offset=interval.wall_offset, calendar=date.calendar + ) + # It's possible that the entire day is skipped. For example, Samoa skipped December 30th 2011. + # We know the two values are in the same calendar here, so we just need to check the YearMonthDay. + if offset_date_time._year_month_day != date._year_month_day: + raise SkippedTimeError(local_date_time=midnight, zone=self) + return ZonedDateTime._ctor(offset_date_time=offset_date_time, zone=self) + # Unambiguous or occurs twice, we can just use the offset from the earlier interval. + case 1 | 2: + return ZonedDateTime._ctor( + offset_date_time=midnight.with_offset(mapping.early_interval.wall_offset), zone=self + ) + case _: + raise RuntimeError("This won't happen.") + + def resolve_local(self, local_date_time: LocalDateTime, resolver: ZoneLocalMappingResolver) -> ZonedDateTime: + """Maps the given ``LocalDateTime`` to the corresponding ``ZonedDateTime``, following the given + ``ZoneLocalMappingResolver`` to handle ambiguity and skipped times. + + This is a convenience method for calling ``map_local`` and passing the result to the resolver. + Common options for resolvers are provided in the static ``Resolvers`` class. + + See ``at_strictly`` and ``at_leniently`` for alternative ways to map a local time to a specific instant. + + :param local_date_time: The local date and time to map in this time zone. + :param resolver: The resolver to apply to the mapping. + :return: The result of resolving the mapping. + """ + _Preconditions._check_not_null(resolver, "resolver") + return resolver(self.map_local(local_date_time=local_date_time)) + + def at_strictly(self, local_date_time: LocalDateTime) -> ZonedDateTime: + """Maps the given ``LocalDateTime`` to the corresponding ``ZonedDateTime``, if and only if that mapping is + unambiguous in this time zone. Otherwise, ``SkippedTimeError`` or ``AmbiguousTimeException`` is thrown, + depending on whether the mapping is ambiguous or the local date/time is skipped entirely. + + See ``at_leniently`` and ``resolve_local(LocalDateTime, ZoneLocalMappingResolver)`` for alternative ways to map + a local time to a specific instant. + + :param local_date_time: The local date and time to map into this time zone. + :raises SkippedTimeError: The given local date/time is skipped in this time zone. + :raises AmbiguousTimeError: The given local date/time is ambiguous in this time zone. + :return: The unambiguous matching ``ZonedDateTime`` if it exists. + """ + return self.resolve_local( + local_date_time=local_date_time, + resolver=Resolvers.strict_resolver, + ) + + def at_leniently(self, local_date_time: LocalDateTime) -> ZonedDateTime: + """Maps the given ``LocalDateTime`` to the corresponding ``ZonedDateTime`` in a lenient manner. + + Ambiguous values map to the earlier of the alternatives, and "skipped" values are shifted forward by the + duration of the "gap". + + See ``at_strictly`` and ``resolve_local(LocalDateTime, ZoneLocalMappingResolver) for alternative ways to map a + local time to a specific instant. + + :param local_date_time: The local date/time to map. + :return: The unambiguous mapping if there is one, the earlier result if the mapping is ambiguous, or the + forward-shifted value if the given local date/time is skipped. + """ + return self.resolve_local(local_date_time=local_date_time, resolver=Resolvers.lenient_resolver) + # endregion def __get_earlier_matching_interval( @@ -287,3 +381,39 @@ def __build_fixed_zone_cache(cls) -> list[DateTimeZone]: _towards_zero_division(-cls.__FIXED_ZONE_CACHE_MINIMUM_SECONDS, cls.__FIXED_ZONE_CACHE_GRANULARITY_SECONDS) ] = cls.utc return ret + + @overload + def get_zone_intervals(self, *, start: Instant, end: Instant) -> Generator[ZoneInterval]: ... + + @overload + def get_zone_intervals(self, *, interval: Interval) -> Generator[ZoneInterval]: ... + + # TODO: + # @overload + # def get_zone_intervals(self, *, interval: Interval, options: ZoneEqualityComparer.Options) -> Generator[ZoneInterval]: # noqa: E501 + # ... + + def get_zone_intervals( + self, + *, + interval: Interval | None = None, + start: Instant | None = None, + end: Instant | None = None, + ) -> Generator[ZoneInterval]: + if start is not None and end is not None and interval is None: + from ._interval import Interval + + interval = Interval(start=start, end=end) + if interval is not None: + + def gen() -> Generator[ZoneInterval]: + current = interval.start if interval.has_start else Instant.min_value + end = interval._raw_end + while current < end: + zone_interval = self.get_zone_interval(current) + yield zone_interval + # If this is the end of time, this will just fail on the next comparison. + current = zone_interval._raw_end + + return gen() + raise TypeError("Called with incorrect arguments") diff --git a/pyoda_time/_instant.py b/pyoda_time/_instant.py index ce99269..4d96478 100644 --- a/pyoda_time/_instant.py +++ b/pyoda_time/_instant.py @@ -8,12 +8,13 @@ import functools from typing import TYPE_CHECKING, Final, Self, cast, final, overload +from ._calendar_system import CalendarSystem from ._duration import Duration +from ._offset_date_time import OffsetDateTime from ._pyoda_constants import PyodaConstants if TYPE_CHECKING: from . import ( - CalendarSystem, DateTimeZone, Offset, ZonedDateTime, @@ -486,15 +487,6 @@ def compare_to(self, other: Instant | None) -> int: # endregion - def in_utc(self) -> ZonedDateTime: - from . import DateTimeZone, LocalDate, OffsetDateTime, OffsetTime, ZonedDateTime - - offset_date_time = OffsetDateTime._ctor( - local_date=LocalDate._ctor(days_since_epoch=self.__duration._floor_days), - offset_time=OffsetTime._ctor(nanosecond_of_day_zero_offset=self.__duration._nanosecond_of_floor_day), - ) - return ZonedDateTime._ctor(offset_date_time=offset_date_time, zone=DateTimeZone.utc) - def __repr__(self) -> str: from pyoda_time._compatibility._culture_info import CultureInfo from pyoda_time.text import InstantPattern @@ -506,3 +498,30 @@ def __format__(self, format_spec: str) -> str: from pyoda_time.text import InstantPattern return InstantPattern._bcl_support.format(self, format_spec, CultureInfo.current_culture) + + def in_utc(self) -> ZonedDateTime: + """Returns the ``ZonedDateTime`` representing the same point in time as this instant, in the UTC time zone and + ISO-8601 calendar. This is a shortcut for calling ``in_zone(DateTimeZone)`` with an argument of + ``DateTimeZone.utc``. + + :return: A ``ZonedDateTime`` for the same instant, in the UTC time zone and the ISO-8601 calendar. + """ + from . import DateTimeZone, LocalDate, OffsetDateTime, OffsetTime, ZonedDateTime + + # Bypass any determination of offset and arithmetic, as we know the offset is zero. + offset_date_time = OffsetDateTime._ctor( + local_date=LocalDate._ctor(days_since_epoch=self.__duration._floor_days), + offset_time=OffsetTime._ctor(nanosecond_of_day_zero_offset=self.__duration._nanosecond_of_floor_day), + ) + return ZonedDateTime._ctor(offset_date_time=offset_date_time, zone=DateTimeZone.utc) + + def with_offset(self, offset: Offset, calendar: CalendarSystem = CalendarSystem.iso) -> OffsetDateTime: + """Returns the ``OffsetDateTime`` representing the same point in time as this instant, with the specified UTC + offset and calendar system (defaulting to ``CalendarSystem.iso``). + + :param offset: The offset from UTC with which to represent this instant. + :param calendar: The calendar system in which to represent this instant. (defaults to ``CalendarSystem.iso``). + :return: An ``OffsetDateTime`` for the same instant, with the given offset and calendar. + """ + _Preconditions._check_not_null(calendar, "calendar") + return OffsetDateTime._ctor(instant=self, offset=offset, calendar=calendar) diff --git a/pyoda_time/_local_date.py b/pyoda_time/_local_date.py index 854d705..a1cb756 100644 --- a/pyoda_time/_local_date.py +++ b/pyoda_time/_local_date.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator - from . import LocalDateTime, LocalTime, Period, YearMonth + from . import DateTimeZone, LocalDateTime, LocalTime, Period, YearMonth, ZonedDateTime from ._year_month_day import _YearMonthDay from ._year_month_day_calendar import _YearMonthDayCalendar @@ -356,6 +356,20 @@ def __trusted_compare_to(self, other: LocalDate) -> int: """ return self.calendar._compare(self._year_month_day, other._year_month_day) + 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. + + This is a convenience method for calling ``DateTimeZone.at_start_of_day(LocalDate)``. + + :param zone: The time zone to map this local date into + :raises SkippedTimeError: The entire day was skipped due to a very large time zone transition. + (This is extremely rare.) + :return: The ``ZonedDateTime`` representing the earliest time on this date, in the given time zone. + """ + _Preconditions._check_not_null(zone, "zone") + return zone.at_start_of_day(self) + def with_calendar(self, calendar: CalendarSystem) -> LocalDate: """Creates a new LocalDate representing the same physical date, but in a different calendar. diff --git a/pyoda_time/_offset_date_time.py b/pyoda_time/_offset_date_time.py index 47ab146..faa6d6e 100644 --- a/pyoda_time/_offset_date_time.py +++ b/pyoda_time/_offset_date_time.py @@ -10,10 +10,11 @@ from ._pyoda_constants import PyodaConstants if TYPE_CHECKING: - from . import LocalDate, LocalDateTime, LocalTime, OffsetTime + from . import IsoDayOfWeek, LocalDate, LocalDateTime, LocalTime, OffsetTime from ._calendar_system import CalendarSystem from ._instant import Instant from ._offset import Offset + from ._year_month_day import _YearMonthDay __all__ = ["OffsetDateTime"] @@ -92,6 +93,66 @@ def year(self) -> int: """ return self.__local_date.year + @property + def month(self) -> int: + """Gets the month of this offset date and time within the year. + + :return: The month of this offset date and time within the year. + """ + return self.__local_date.month + + @property + def day(self) -> int: + """Gets the day of this offset date and time within the month. + + :return: The day of this offset date and time within the month. + """ + return self.__local_date.day + + @property + def _year_month_day(self) -> _YearMonthDay: + return self.__local_date._year_month_day + + @property + def day_of_week(self) -> IsoDayOfWeek: + """Gets the week day of this offset date and time expressed as an ``IsoDayOfWeek``. + + :return: The week day of this offset date and time expressed as an ``IsoDayOfWeek``. + """ + return self.__local_date.day_of_week + + @property + def day_of_year(self) -> int: + """Gets the day of this offset date and time within the year. + + :return: The day of this offset date and time within the year. + """ + return self.__local_date.day_of_year + + @property + def hour(self) -> int: + """Gets the hour of day of this offset date and time, in the range 0 to 23 inclusive. + + :return: The hour of day of this offset date and time, in the range 0 to 23 inclusive. + """ + return self.__offset_time.hour + + @property + def minute(self) -> int: + """Gets the minute of this offset date and time, in the range 0 to 59 inclusive. + + :return: The minute of this offset date and time, in the range 0 to 59 inclusive. + """ + return self.__offset_time.minute + + @property + def second(self) -> int: + """Gets the second of this offset date and time within the minute, in the range 0 to 59 inclusive. + + :return: The second of this offset date and time within the minute, in the range 0 to 59 inclusive. + """ + return self.__offset_time.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 diff --git a/pyoda_time/_skipped_time_error.py b/pyoda_time/_skipped_time_error.py index a40bb35..4a06f5a 100644 --- a/pyoda_time/_skipped_time_error.py +++ b/pyoda_time/_skipped_time_error.py @@ -56,7 +56,7 @@ def __init__(self, local_date_time: LocalDateTime, zone: DateTimeZone) -> None: :param zone: The time zone in which the local date/time does not exist. """ super().__init__( - f"Local time {local_date_time} is invalid in time zone {_Preconditions._check_not_null(zone, "zone").id}" + f"Local time {local_date_time} is invalid in time zone {_Preconditions._check_not_null(zone, 'zone').id}" ) self.__local_date_time = local_date_time self.__zone = zone diff --git a/pyoda_time/_zoned_date_time.py b/pyoda_time/_zoned_date_time.py index c145ae7..c5ae4cc 100644 --- a/pyoda_time/_zoned_date_time.py +++ b/pyoda_time/_zoned_date_time.py @@ -14,6 +14,7 @@ CalendarSystem, DateTimeZone, Instant, + IsoDayOfWeek, LocalDate, LocalDateTime, LocalTime, @@ -143,6 +144,62 @@ def year(self) -> int: """ return self.__offset_date_time.year + @property + def month(self) -> int: + """Gets the month of this zoned date and time within the year. + + :return: The month of this zoned date and time within the year. + """ + return self.__offset_date_time.month + + @property + def day_of_year(self) -> int: + """Gets the day of this zoned date and time within the year. + + :return: The day of this zoned date and time within the year. + """ + return self.__offset_date_time.day_of_year + + @property + def day(self) -> int: + """Gets the day of this zoned date and time within the month. + + :return: The day of this zoned date and time within the month. + """ + return self.__offset_date_time.day + + @property + def day_of_week(self) -> IsoDayOfWeek: + """Gets the week day of this zoned date and time expressed as an ``IsoDayOfWeek`` value. + + :return: The week day of this zoned date and time expressed as an ``IsoDayOfWeek`` value. + """ + return self.__offset_date_time.day_of_week + + @property + def hour(self) -> int: + """Gets the hour of day of this zoned date and time, in the range 0 to 23 inclusive. + + :return: The hour of day of this zoned date and time, in the range 0 to 23 inclusive. + """ + return self.__offset_date_time.hour + + @property + def minute(self) -> int: + """Gets the minute of this zoned date and time, in the range 0 to 59 inclusive. + + :return: The minute of this zoned date and time, in the range 0 to 59 inclusive. + """ + return self.__offset_date_time.minute + + @property + def second(self) -> int: + """Gets the second of this zoned date and time within the minute, in the range 0 to 59 inclusive. + + :return: The second of this zoned date and time within the minute, in the range 0 to 59 inclusive. + """ + return self.__offset_date_time.second + def to_instant(self) -> Instant: """Converts this value to the instant it represents on the timeline. diff --git a/pyoda_time/calendars/_islamic_year_month_day_calculator.py b/pyoda_time/calendars/_islamic_year_month_day_calculator.py index 352736a..2c26940 100644 --- a/pyoda_time/calendars/_islamic_year_month_day_calculator.py +++ b/pyoda_time/calendars/_islamic_year_month_day_calculator.py @@ -165,4 +165,4 @@ def __get_year_10_days(cls, epoch: IslamicEpoch) -> int: return cls.__DAYS_AT_CIVIL_EPOCH case _: # TODO: ArgumentOutOfRangeException? - raise ValueError(f"Epoch {getattr(epoch, "name", epoch)} not recognised") + raise ValueError(f"Epoch {getattr(epoch, 'name', epoch)} not recognised") diff --git a/pyoda_time/time_zones/_resolvers.py b/pyoda_time/time_zones/_resolvers.py index 2481eca..562d4d4 100644 --- a/pyoda_time/time_zones/_resolvers.py +++ b/pyoda_time/time_zones/_resolvers.py @@ -20,14 +20,9 @@ from ._delegates import AmbiguousTimeResolver, SkippedTimeResolver, ZoneLocalMappingResolver -class Resolvers: - """Commonly-used implementations of the delegates used in resolving a ``LocalDateTime`` to a ``ZonedDateTime``, and - a method to combine two "partial" resolvers into a full one. - - This class contains predefined implementations of ``ZoneLocalMappingResolver``, ``AmbiguousTimeResolver``, and - ``SkippedTimeResolver``, along with ``CreateMappingResolver``, which produces a ``ZoneLocalMappingResolver`` - from instances of the other two. - """ +class __ResolversMeta(type): + __strict_resolver: ZoneLocalMappingResolver | None = None + __lenient_resolver: ZoneLocalMappingResolver | None = None @staticmethod def return_earlier(earlier: ZonedDateTime, later: ZonedDateTime) -> ZonedDateTime: @@ -109,6 +104,41 @@ def throw_when_skipped( _Preconditions._check_not_null(interval_after, "interval_after") raise SkippedTimeError(local_date_time, zone) + @property + def strict_resolver(self) -> ZoneLocalMappingResolver: + """A ``ZoneLocalMappingResolver`` which only ever succeeds in the (usual) case where the result of the mapping + is unambiguous. + + If the mapping is ambiguous or skipped, this raises ``SkippedTimeError`` or ``AmbiguousTimeError`` as + appropriate. + + This resolver combines ``throw_when_ambiguous`` and ``throw_when_skipped``. + + :return: A ``ZoneLocalMappingResolver`` which only ever succeeds in the (usual) case where the result of the + mapping is unambiguous. + """ + if (strict_resolver := self.__strict_resolver) is None: + strict_resolver = self.__strict_resolver = self.create_mapping_resolver( + ambiguous_time_resolver=self.throw_when_ambiguous, skipped_time_resolver=self.throw_when_skipped + ) + return strict_resolver + + @property + def lenient_resolver(self) -> ZoneLocalMappingResolver: + """A ``ZoneLocalMappingResolver`` which never raises an exception due to ambiguity or skipped time. + + Ambiguity is handled by returning the earlier occurrence, and skipped times are shifted forward by the duration + of the gap. This resolver combines ``return_earlier`` and ``return_forward_shifted``. + + :return: A ``ZoneLocalMappingResolver`` which never throws an exception due to ambiguity or skipped time. + """ + if (lenient_resolver := self.__lenient_resolver) is None: + lenient_resolver = self.__lenient_resolver = self.create_mapping_resolver( + ambiguous_time_resolver=self.return_earlier, + skipped_time_resolver=self.return_forward_shifted, + ) + return lenient_resolver + @staticmethod def create_mapping_resolver( ambiguous_time_resolver: AmbiguousTimeResolver, skipped_time_resolver: SkippedTimeResolver @@ -140,3 +170,13 @@ def func(mapping: ZoneLocalMapping) -> ZonedDateTime: raise ValueError("Mapping has count outside range 0-2; should not happen.") return func + + +class Resolvers(metaclass=__ResolversMeta): + """Commonly-used implementations of the delegates used in resolving a ``LocalDateTime`` to a ``ZonedDateTime``, and + a method to combine two "partial" resolvers into a full one. + + This class contains predefined implementations of ``ZoneLocalMappingResolver``, ``AmbiguousTimeResolver``, and + ``SkippedTimeResolver``, along with ``CreateMappingResolver``, which produces a ``ZoneLocalMappingResolver`` + from instances of the other two. + """ diff --git a/pyoda_time/time_zones/_zone_interval.py b/pyoda_time/time_zones/_zone_interval.py index fd1ed52..e3fcf9b 100644 --- a/pyoda_time/time_zones/_zone_interval.py +++ b/pyoda_time/time_zones/_zone_interval.py @@ -233,7 +233,7 @@ def __hash__(self) -> int: self.savings, ) - def __str__(self) -> str: + def __repr__(self) -> str: # TODO: Only the simplest case in the default culture is covered (kind of) return f"{self.name}: [{self.__raw_start}, {self._raw_end}) {self.wall_offset} ({self.savings})" diff --git a/tests/calendars/test_um_al_qura_year_month_day_calculator.py b/tests/calendars/test_um_al_qura_year_month_day_calculator.py index 037d288..4634298 100644 --- a/tests/calendars/test_um_al_qura_year_month_day_calculator.py +++ b/tests/calendars/test_um_al_qura_year_month_day_calculator.py @@ -40,9 +40,9 @@ def test_get_days_from_start_of_year_to_start_of_month(self) -> None: for year in range(calculator._min_year, calculator._max_year + 1): day_of_year = 1 for month in range(1, 13): - assert ( - calculator._get_day_of_year(_YearMonthDay._ctor(year=year, month=month, day=1)) == day_of_year - ), f"{year=}, {month=}" + assert calculator._get_day_of_year(_YearMonthDay._ctor(year=year, month=month, day=1)) == day_of_year, ( + f"{year=}, {month=}" + ) day_of_year += calculator._get_days_in_month(year, month) def test_get_year_month_day_invalid_value_for_coverage(self) -> None: diff --git a/tests/test_date_time_zone.py b/tests/test_date_time_zone.py new file mode 100644 index 0000000..857649a --- /dev/null +++ b/tests/test_date_time_zone.py @@ -0,0 +1,607 @@ +# 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 ( + AmbiguousTimeError, + DateTimeZone, + DateTimeZoneProviders, + Duration, + Instant, + Interval, + IsoDayOfWeek, + LocalDate, + LocalDateTime, + LocalTime, + Offset, + PyodaConstants, + SkippedTimeError, + ZonedDateTime, +) +from pyoda_time.testing.time_zones import SingleTransitionDateTimeZone +from pyoda_time.text import LocalDatePattern +from pyoda_time.time_zones import Resolvers, ZoneInterval, ZoneLocalMapping + + +class TestDateTimeZone: + def test_for_offset_uncached_example_not_on_half_hour(self) -> None: + """The current implementation caches every half hour, -12 to +15.""" + offset = Offset.from_seconds(123) + zone1 = DateTimeZone.for_offset(offset) + zone2 = DateTimeZone.for_offset(offset) + + assert zone1 is not zone2 + assert zone1._is_fixed + assert zone1.max_offset == offset + assert zone1.min_offset == offset + + def test_for_offset_uncached_example_outside_cache_range(self) -> None: + offset = Offset.from_hours(-14) + zone1 = DateTimeZone.for_offset(offset) + zone2 = DateTimeZone.for_offset(offset) + + assert zone1 is not zone2 + assert zone1._is_fixed + assert zone1.max_offset == offset + assert zone1.min_offset == offset + + def test_for_offset_cached_example(self) -> None: + offset = Offset.from_hours(2) + zone1 = DateTimeZone.for_offset(offset) + zone2 = DateTimeZone.for_offset(offset) + # Caching check... + assert zone1 is zone2 + + assert zone1._is_fixed + assert zone1.max_offset == offset + assert zone1.min_offset == offset + + def test_for_offset_zero_same_as_utc(self) -> None: + assert DateTimeZone.for_offset(Offset.zero) is DateTimeZone.utc + + +class TestDateTimeZoneGetZoneIntervals: + TEST_ZONE = SingleTransitionDateTimeZone( + transition_point=Instant.from_utc(2000, 1, 1, 0, 0), + offset_before=-3, + offset_after=4, + ) + + def test_get_zone_intervals_end_before_start(self) -> None: + with pytest.raises(ValueError): # TODO: ArgumentOutOfRangeException + DateTimeZone.utc.get_zone_intervals( + start=Instant.from_unix_time_ticks(100), + end=Instant.from_unix_time_ticks(99), + ) + + def test_get_zone_intervals_end_equal_to_start(self) -> None: + zone_intervals = DateTimeZone.utc.get_zone_intervals( + start=Instant.from_unix_time_ticks(100), + end=Instant.from_unix_time_ticks(100), + ) + assert list(zone_intervals) == [] + + # TODO: def test_get_zone_intervals_invalid_options(self) -> None: + + def test_get_zone_intervals_fixed_zone(self) -> None: + zone = DateTimeZone.for_offset(Offset.from_hours(3)) + expected = {zone.get_zone_interval(Instant.min_value)} + # Give a reasonably wide interval... + actual = zone.get_zone_intervals( + start=Instant.from_utc(1900, 1, 1, 0, 0), + end=Instant.from_utc(2100, 1, 1, 0, 0), + ) + assert set(actual) == expected + + def test_get_zone_intervals_single_transition_zone_interval_covers_transition(self) -> None: + start: Instant = self.TEST_ZONE.transition - Duration.from_days(5) + end: Instant = self.TEST_ZONE.transition + Duration.from_days(5) + expected = {self.TEST_ZONE.early_interval, self.TEST_ZONE.late_interval} + actual = set(self.TEST_ZONE.get_zone_intervals(start=start, end=end)) + assert actual == expected + + def test_get_zone_intervals_single_transition_zone_interval_does_not_cover_transition(self) -> None: + start: Instant = self.TEST_ZONE.transition - Duration.from_days(10) + end: Instant = self.TEST_ZONE.transition - Duration.from_days(5) + expected = {self.TEST_ZONE.early_interval} + actual = set(self.TEST_ZONE.get_zone_intervals(start=start, end=end)) + assert actual == expected + + def test_get_zone_intervals_includes_start(self) -> None: + start: Instant = self.TEST_ZONE.transition - Duration.epsilon + end: Instant = self.TEST_ZONE.transition + Duration.from_days(5) + expected = {self.TEST_ZONE.early_interval, self.TEST_ZONE.late_interval} + actual = set(self.TEST_ZONE.get_zone_intervals(start=start, end=end)) + assert actual == expected + + def test_get_zone_intervals_excludes_end(self) -> None: + start: Instant = self.TEST_ZONE.transition - Duration.from_days(10) + end: Instant = self.TEST_ZONE.transition + expected = {self.TEST_ZONE.early_interval} + actual = set(self.TEST_ZONE.get_zone_intervals(start=start, end=end)) + assert actual == expected + + def test_get_zone_intervals_complex(self) -> None: + london = DateTimeZoneProviders.tzdb["Europe/London"] + # Transitions are always Spring/Autumn, so June and January should be clear. + expected = { + london.get_zone_interval(Instant.from_utc(1999, 6, 1, 0, 0)), + london.get_zone_interval(Instant.from_utc(2000, 1, 1, 0, 0)), + london.get_zone_interval(Instant.from_utc(2000, 6, 1, 0, 0)), + london.get_zone_interval(Instant.from_utc(2001, 1, 1, 0, 0)), + london.get_zone_interval(Instant.from_utc(2001, 6, 1, 0, 0)), + london.get_zone_interval(Instant.from_utc(2002, 1, 1, 0, 0)), + } + # After the instant we used to fetch the expected zone interval, but that's fine: + # it'll be the same one, as there's no transition within June. + start = Instant.from_utc(1999, 6, 19, 0, 0) + end = Instant.from_utc(2002, 2, 4, 0, 0) + actual = set(london.get_zone_intervals(start=start, end=end)) + assert actual == expected + # Just to exercise the other overload + actual = set(london.get_zone_intervals(interval=Interval(start=start, end=end))) + assert actual == expected + + # TODO: def test_get_zone_intervals_with_options_no_coalescing(self) -> None: + # TODO: def test_get_zone_intervals_with_options_coalescing(self) -> None: + + +class TestDateTimeZoneIds: + def test_utc_is_not_null(self) -> None: + assert DateTimeZone.utc is not None + + +class TestDateTimeZoneLocalConversion: + """Tests for aspects of DateTimeZone to do with converting from LocalDateTime and LocalDate to ZonedDateTime.""" + + # Sample time zones for DateTimeZone.AtStartOfDay etc. I didn't want to only test midnight transitions. + LOS_ANGELES: Final[DateTimeZone] = DateTimeZoneProviders.tzdb["America/Los_Angeles"] + NEW_ZEALAND: Final[DateTimeZone] = DateTimeZoneProviders.tzdb["Pacific/Auckland"] + PARIS: Final[DateTimeZone] = DateTimeZoneProviders.tzdb["Europe/Paris"] + NEW_YORK: Final[DateTimeZone] = DateTimeZoneProviders.tzdb["America/New_York"] + PACIFIC: Final[DateTimeZone] = DateTimeZoneProviders.tzdb["America/Los_Angeles"] + + TRANSITION_FORWARD_AT_MIDNIGHT_ZONE: Final[DateTimeZone] = SingleTransitionDateTimeZone( + transition_point=Instant.from_utc(2000, 6, 1, 2, 0), + offset_before=Offset.from_hours(-2), + offset_after=Offset.from_hours(-1), + ) + """Local midnight at the start of the transition (June 1st) becomes 1am.""" + + TRANSITION_BACKWARD_TO_MIDNIGHT_ZONE: Final[DateTimeZone] = SingleTransitionDateTimeZone( + transition_point=Instant.from_utc(2000, 6, 1, 3, 0), + offset_before=Offset.from_hours(-2), + offset_after=Offset.from_hours(-3), + ) + """Local 1am at the start of the transition (June 1st) becomes midnight.""" + + TRANSITION_FORWARD_BEFORE_MIDNIGHT_ZONE: Final[DateTimeZone] = SingleTransitionDateTimeZone( + transition_point=Instant.from_utc(2000, 6, 1, 1, 20), + offset_before=Offset.from_hours(-2), + offset_after=Offset.from_hours(-1), + ) + """Local 11.20pm at the start of the transition (May 30th) becomes 12.20am of June 1st.""" + + TRANSITION_BACKWARD_AFTER_MIDNIGHT_ZONE: Final[DateTimeZone] = SingleTransitionDateTimeZone( + transition_point=Instant.from_utc(2000, 6, 1, 2, 20), + offset_before=Offset.from_hours(-2), + offset_after=Offset.from_hours(-3), + ) + """Local 12.20am at the start of the transition (June 1st) becomes 11.20pm of the previous day.""" + + TRANSITION_DATE: Final[LocalDate] = LocalDate(2000, 6, 1) + + def test_ambiguous_start_of_day_transition_at_midnight(self) -> None: + # Occurrence before transition + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2000, 6, 1, 0, 0).with_offset(Offset.from_hours(-2)), + zone=self.TRANSITION_BACKWARD_TO_MIDNIGHT_ZONE, + ) + actual = self.TRANSITION_BACKWARD_TO_MIDNIGHT_ZONE.at_start_of_day(self.TRANSITION_DATE) + assert actual == expected + assert self.TRANSITION_DATE.at_start_of_day_in_zone(self.TRANSITION_BACKWARD_TO_MIDNIGHT_ZONE) == expected + + def test_ambiguous_start_of_day_transition_after_midnight(self) -> None: + # Occurrence before transition + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2000, 6, 1, 0, 0).with_offset(Offset.from_hours(-2)), + zone=self.TRANSITION_BACKWARD_AFTER_MIDNIGHT_ZONE, + ) + actual = self.TRANSITION_BACKWARD_AFTER_MIDNIGHT_ZONE.at_start_of_day(self.TRANSITION_DATE) + assert actual == expected + assert self.TRANSITION_DATE.at_start_of_day_in_zone(self.TRANSITION_BACKWARD_AFTER_MIDNIGHT_ZONE) == expected + + def test_skipped_start_of_day_transition_at_midnight(self) -> None: + # 1am because of the skip + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2000, 6, 1, 1, 0).with_offset(Offset.from_hours(-1)), + zone=self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE, + ) + actual = self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE.at_start_of_day(self.TRANSITION_DATE) + assert actual == expected + assert self.TRANSITION_DATE.at_start_of_day_in_zone(self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE) == expected + + def test_skipped_start_of_day_transition_before_midnight(self) -> None: + # 12.20am because of the skip + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2000, 6, 1, 0, 20).with_offset(Offset.from_hours(-1)), + zone=self.TRANSITION_FORWARD_BEFORE_MIDNIGHT_ZONE, + ) + actual = self.TRANSITION_FORWARD_BEFORE_MIDNIGHT_ZONE.at_start_of_day(self.TRANSITION_DATE) + assert actual == expected + assert self.TRANSITION_DATE.at_start_of_day_in_zone(self.TRANSITION_FORWARD_BEFORE_MIDNIGHT_ZONE) == expected + + def test_unambiguous_start_of_day(self) -> None: + # Just a simple midnight in March. + expected = ZonedDateTime._ctor( + offset_date_time=LocalDateTime(2000, 3, 1, 0, 0).with_offset(Offset.from_hours(-2)), + zone=self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE, + ) + actual = self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE.at_start_of_day(LocalDate(2000, 3, 1)) + assert actual == expected + assert LocalDate(2000, 3, 1).at_start_of_day_in_zone(self.TRANSITION_FORWARD_AT_MIDNIGHT_ZONE) == expected + + @staticmethod + def assert_impossible(local_time: LocalDateTime, zone: DateTimeZone) -> None: + mapping = zone.map_local(local_time) + assert mapping.count == 0 + with pytest.raises(SkippedTimeError) as e: + mapping.single() + assert e.value.local_date_time == local_time + assert e.value.zone == zone + # TODO: Assert.Null(e.ParamName); + assert zone.id in str(e.value) + + with pytest.raises(SkippedTimeError) as e: + mapping.first() + assert e.value.local_date_time == local_time + assert e.value.zone == zone + + with pytest.raises(SkippedTimeError) as e: + mapping.last() + assert e.value.local_date_time == local_time + assert e.value.zone == zone + + @staticmethod + def assert_ambiguous(local_time: LocalDateTime, zone: DateTimeZone) -> None: + earlier: ZonedDateTime = zone.map_local(local_time).first() + later: ZonedDateTime = zone.map_local(local_time).last() + assert earlier.local_date_time == local_time + assert later.local_date_time == local_time + assert earlier.to_instant() < later.to_instant() + + mapping = zone.map_local(local_time) + assert mapping.count == 2 + with pytest.raises(AmbiguousTimeError) as e: + mapping.single() + assert e.value.local_date_time == local_time + assert e.value.zone == zone + assert e.value.earlier_mapping == earlier + assert e.value.later_mapping == later + assert zone.id in str(e.value) + + assert mapping.first() == earlier + assert mapping.last() == later + + @staticmethod + def assert_offset(expected_hours: int, local_time: LocalDateTime, zone: DateTimeZone) -> None: + mapping = zone.map_local(local_time) + assert mapping.count == 1 + zoned = mapping.single() + assert mapping.first() == zoned + assert mapping.last() == zoned + actual_hours = zoned.offset.milliseconds / PyodaConstants.MILLISECONDS_PER_HOUR + assert actual_hours == expected_hours + + def test_get_offset_from_local_los_angeles_fall_transition(self) -> None: + """Los Angeles goes from -7 to -8 on November 7th 2010 at 2am wall time.""" + before = LocalDateTime(2010, 11, 7, 0, 30) + at_transition = LocalDateTime(2010, 11, 7, 1, 0) + ambiguous = LocalDateTime(2010, 11, 7, 1, 30) + after = LocalDateTime(2010, 11, 7, 2, 30) + self.assert_offset(-7, before, self.LOS_ANGELES) + self.assert_ambiguous(at_transition, self.LOS_ANGELES) + self.assert_ambiguous(ambiguous, self.LOS_ANGELES) + self.assert_offset(-8, after, self.LOS_ANGELES) + + def test_get_offset_from_local_los_angeles_spring_transition(self) -> None: + before = LocalDateTime(2010, 3, 14, 1, 30) + impossible = LocalDateTime(2010, 3, 14, 2, 30) + at_transition = LocalDateTime(2010, 3, 14, 3, 0) + after = LocalDateTime(2010, 3, 14, 3, 30) + self.assert_offset(-8, before, self.LOS_ANGELES) + self.assert_impossible(impossible, self.LOS_ANGELES) + self.assert_offset(-7, at_transition, self.LOS_ANGELES) + self.assert_offset(-7, after, self.LOS_ANGELES) + + def test_get_offset_from_local_new_zealand_fall_transition(self) -> None: + """New Zealand goes from +13 to +12 on April 4th 2010 at 3am wall time.""" + before = LocalDateTime(2010, 4, 4, 1, 30) + at_transition = LocalDateTime(2010, 4, 4, 2, 0) + ambiguous = LocalDateTime(2010, 4, 4, 2, 30) + after = LocalDateTime(2010, 4, 4, 3, 30) + self.assert_offset(13, before, self.NEW_ZEALAND) + self.assert_ambiguous(at_transition, self.NEW_ZEALAND) + self.assert_ambiguous(ambiguous, self.NEW_ZEALAND) + self.assert_offset(12, after, self.NEW_ZEALAND) + + def test_get_offset_from_local_new_zealand_spring_transition(self) -> None: + """New Zealand goes from +12 to +13 on September 26th 2010 at 2am wall time.""" + before = LocalDateTime(2010, 9, 26, 1, 30) + impossible = LocalDateTime(2010, 9, 26, 2, 30) + at_transition = LocalDateTime(2010, 9, 26, 3, 0) + after = LocalDateTime(2010, 9, 26, 3, 30) + self.assert_offset(12, before, self.NEW_ZEALAND) + self.assert_impossible(impossible, self.NEW_ZEALAND) + self.assert_offset(13, at_transition, self.NEW_ZEALAND) + self.assert_offset(13, after, self.NEW_ZEALAND) + + def test_get_offset_from_local_paris_fall_transition(self) -> None: + """Paris goes from +1 to +2 on March 28th 2010 at 2am wall time.""" + before = LocalDateTime(2010, 10, 31, 1, 30) + at_transition = LocalDateTime(2010, 10, 31, 2, 0) + ambiguous = LocalDateTime(2010, 10, 31, 2, 30) + after = LocalDateTime(2010, 10, 31, 3, 30) + self.assert_offset(2, before, self.PARIS) + self.assert_ambiguous(ambiguous, self.PARIS) + self.assert_ambiguous(at_transition, self.PARIS) + self.assert_offset(1, after, self.PARIS) + + def test_get_offset_from_local_paris_spring_transition(self) -> None: + before = LocalDateTime(2010, 3, 28, 1, 30) + impossible = LocalDateTime(2010, 3, 28, 2, 30) + at_transition = LocalDateTime(2010, 3, 28, 3, 0) + after = LocalDateTime(2010, 3, 28, 3, 30) + self.assert_offset(1, before, self.PARIS) + self.assert_impossible(impossible, self.PARIS) + self.assert_offset(2, at_transition, self.PARIS) + self.assert_offset(2, after, self.PARIS) + + def test_map_local_date_time_unambiguous_date_returns_unambiguous_mapping(self) -> None: + # 2011-11-09 01:30:00 - not ambiguous in America/New York timezone + unambiguous_time = LocalDateTime(2011, 11, 9, 1, 30) + mapping = self.NEW_YORK.map_local(unambiguous_time) + assert mapping.count == 1 + + def test_map_local_date_time_ambiguous_date_returns_ambiguous_mapping(self) -> None: + # 2011-11-06 01:30:00 - falls during DST - EST conversion in America/New York timezone + ambiguous_time = LocalDateTime(2011, 11, 6, 1, 30) + mapping = self.NEW_YORK.map_local(ambiguous_time) + assert mapping.count == 2 + + def test_map_local_date_time_skipped_date_returns_skipped_mapping(self) -> None: + # 2011-03-13 02:30:00 - falls during EST - DST conversion in America/New York timezone + skipped_time = LocalDateTime(2011, 3, 13, 2, 30) + mapping = self.NEW_YORK.map_local(skipped_time) + assert mapping.count == 0 + + @pytest.mark.parametrize( + ("zone_id", "local_date"), + [ + ("Pacific/Apia", "2011-12-30"), + ("Pacific/Enderbury", "1994-12-31"), + ("Pacific/Kiritimati", "1994-12-31"), + ("Pacific/Kwajalein", "1993-08-21"), + ], + ) + def test_at_start_of_day_day_doesnt_exist(self, zone_id: str, local_date: str) -> None: + """Some zones skipped dates by changing from UTC-lots to UTC+lots. + + For example, Samoa (Pacific/Apia) skipped December 30th 2011, going from 23:59:59 December 29th local time + UTC-10 to 00:00:00 December 31st local time UTC+14 + """ + bad_date: LocalDate = LocalDatePattern.iso.parse(local_date).value + zone: DateTimeZone = DateTimeZoneProviders.tzdb[zone_id] + with pytest.raises(SkippedTimeError) as e: + zone.at_start_of_day(bad_date) + assert e.value.local_date_time == bad_date + LocalTime.midnight + + def test_at_strictly_in_winter(self) -> None: + when = self.PACIFIC.at_strictly(LocalDateTime(2009, 12, 22, 21, 39, 30)) + + assert when.year == 2009 + assert when.month == 12 + assert when.day == 22 + assert when.day_of_week == IsoDayOfWeek.TUESDAY + assert when.hour == 21 + assert when.minute == 39 + assert when.second == 30 + assert when.offset == Offset.from_hours(-8) + + def test_at_strictly_in_summer(self) -> None: + when = self.PACIFIC.at_strictly(LocalDateTime(2009, 6, 22, 21, 39, 30)) + + assert when.year == 2009 + assert when.month == 6 + assert when.day == 22 + assert when.day_of_week == IsoDayOfWeek.MONDAY + assert when.hour == 21 + assert when.minute == 39 + assert when.second == 30 + assert when.offset == Offset.from_hours(-7) + + def test_at_strictly_throws_when_ambiguous(self) -> None: + """Pacific time changed from -7 to -8 at 2am wall time on November 2nd 2009, so 2am became 1am.""" + with pytest.raises(AmbiguousTimeError): + self.PACIFIC.at_strictly(LocalDateTime(2009, 11, 1, 1, 30, 0)) + + def test_at_strictly_throws_when_skipped(self) -> None: + """Pacific time changed from -8 to -7 at 2am wall time on March 8th 2009, so 2am became 3am. + + This means that 2.30am doesn't exist on that day. + """ + with pytest.raises(SkippedTimeError): + self.PACIFIC.at_strictly(LocalDateTime(2009, 3, 8, 2, 30, 0)) + + def test_at_leniently_ambiguous_time_returns_earlier_mapping(self) -> None: + """Pacific time changed from -7 to -8 at 2am wall time on November 2nd 2009, so 2am became 1am. + + We'll return the earlier result, i.e. with the offset of -7 + """ + local = LocalDateTime(2009, 11, 1, 1, 30, 0) + zoned = self.PACIFIC.at_leniently(local) + assert zoned.local_date_time == local + assert zoned.offset == Offset.from_hours(-7) + + def test_at_leniently_returns_forward_shifted_value(self) -> None: + """Pacific time changed from -8 to -7 at 2am wall time on March 8th 2009, so 2am became 3am. + + This means that 2:30am doesn't exist on that day. We'll return 3:30am, the forward-shifted value. + """ + local = LocalDateTime(2009, 3, 8, 2, 30, 0) + zoned = self.PACIFIC.at_leniently(local) + assert zoned.local_date_time == LocalDateTime(2009, 3, 8, 3, 30, 0) + assert zoned.offset == Offset.from_hours(-7) + + def test_resolve_local(self) -> None: + # Don't need much for this - it only delegates. + ambiguous = LocalDateTime(2009, 11, 1, 1, 30, 0) + skipped = LocalDateTime(2009, 3, 8, 2, 30, 0) + assert self.PACIFIC.resolve_local(ambiguous, Resolvers.lenient_resolver) == self.PACIFIC.at_leniently(ambiguous) + assert self.PACIFIC.resolve_local(skipped, Resolvers.lenient_resolver) == self.PACIFIC.at_leniently(skipped) + + +class TestDateTimeZoneMapLocal: + """Tests for MapLocal within DateTimeZone. We have two zones, each with a single transition at midnight January 1st + 2000. One goes from -5 to +10, i.e. skips from 7pm Dec 31st to 10am Jan 1st The other goes from +10 to -5, i.e. goes + from 10am Jan 1st back to 7pm Dec 31st. + + Both zones are tested for the zone interval pairs at: + - The start of time + - The end of time + - A local time well before the transition + - A local time well after the transition + - An unambiguous local time shortly before the transition + - An unambiguous local time shortly after the transition + - The start of the transition + - In the middle of the gap / ambiguity + - The last local instant of the gap / ambiguity + - The local instant immediately after the gap / ambiguity + """ + + TRANSITION: Final[Instant] = Instant.from_utc(2000, 1, 1, 0, 0) + + MINUS_5: Final[Offset] = Offset.from_hours(-5) + PLUS_10: Final[Offset] = Offset.from_hours(10) + + NEAR_START_OF_TIME: Final[LocalDateTime] = LocalDateTime(-9998, 1, 5, 0, 0) + NEAR_END_OF_TIME: Final[LocalDateTime] = LocalDateTime(9999, 12, 25, 0, 0) + TRANSITION_MINUS_5: Final[LocalDateTime] = TRANSITION.with_offset(MINUS_5).local_date_time + TRANSITION_PLUS_10: Final[LocalDateTime] = TRANSITION.with_offset(PLUS_10).local_date_time + MID_TRANSITION: Final[LocalDateTime] = TRANSITION.with_offset(Offset.zero).local_date_time + + YEAR_BEFORE_TRANSITION: Final[LocalDateTime] = LocalDateTime(1999, 1, 1, 0, 0) + YEAR_AFTER_TRANSITION: Final[LocalDateTime] = LocalDateTime(2001, 1, 1, 0, 0) + + ZONE_WITH_GAP: Final[SingleTransitionDateTimeZone] = SingleTransitionDateTimeZone( + transition_point=TRANSITION, offset_before=MINUS_5, offset_after=PLUS_10 + ) + INTERVAL_BEFORE_GAP: Final[ZoneInterval] = ZONE_WITH_GAP.early_interval + INTERVAL_AFTER_GAP: Final[ZoneInterval] = ZONE_WITH_GAP.late_interval + + ZONE_WITH_AMBIGUITY: Final[SingleTransitionDateTimeZone] = SingleTransitionDateTimeZone( + transition_point=TRANSITION, offset_before=PLUS_10, offset_after=MINUS_5 + ) + INTERVAL_BEFORE_AMBIGUITY: Final[ZoneInterval] = ZONE_WITH_AMBIGUITY.early_interval + INTERVAL_AFTER_AMBIGUITY: Final[ZoneInterval] = ZONE_WITH_AMBIGUITY.late_interval + + def test_zone_with_ambiguity_near_start_of_time(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(LocalDateTime(-9998, 1, 5, 0, 0)) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_BEFORE_AMBIGUITY, 1) + + def test_zone_with_ambiguity_near_end_of_time(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.NEAR_END_OF_TIME) + self.check_mapping(mapping, self.INTERVAL_AFTER_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 1) + + def test_zone_with_ambiguity_well_before_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.YEAR_BEFORE_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_BEFORE_AMBIGUITY, 1) + + def test_zone_with_ambiguity_well_after_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.YEAR_AFTER_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_AFTER_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 1) + + def test_zone_with_ambiguity_just_before_ambiguity(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.TRANSITION_MINUS_5.plus_nanoseconds(-1)) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_BEFORE_AMBIGUITY, 1) + + def test_zone_with_ambiguity_just_after_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.TRANSITION_PLUS_10.plus_nanoseconds(1)) + self.check_mapping(mapping, self.INTERVAL_AFTER_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 1) + + def test_zone_with_ambiguity_start_of_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.TRANSITION_MINUS_5) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 2) + + def test_zone_with_ambiguity_mid_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.MID_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 2) + + def test_zone_with_ambiguity_last_tick_of_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.TRANSITION_PLUS_10.plus_nanoseconds(-1)) + self.check_mapping(mapping, self.INTERVAL_BEFORE_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 2) + + def test_zone_with_ambiguity_first_tick_after_transition(self) -> None: + mapping = self.ZONE_WITH_AMBIGUITY.map_local(self.TRANSITION_PLUS_10) + self.check_mapping(mapping, self.INTERVAL_AFTER_AMBIGUITY, self.INTERVAL_AFTER_AMBIGUITY, 1) + + def test_zone_with_gap_near_start_of_time(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.NEAR_START_OF_TIME) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_BEFORE_GAP, 1) + + def test_zone_with_gap_near_end_of_time(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.NEAR_END_OF_TIME) + self.check_mapping(mapping, self.INTERVAL_AFTER_GAP, self.INTERVAL_AFTER_GAP, 1) + + def test_zone_with_gap_well_before_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.YEAR_BEFORE_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_BEFORE_GAP, 1) + + def test_zone_with_gap_well_after_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.YEAR_AFTER_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_AFTER_GAP, self.INTERVAL_AFTER_GAP, 1) + + def test_zone_with_gap_just_before_gap(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.TRANSITION_MINUS_5.plus_nanoseconds(-1)) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_BEFORE_GAP, 1) + + def test_zone_with_gap_just_after_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.TRANSITION_PLUS_10.plus_nanoseconds(1)) + self.check_mapping(mapping, self.INTERVAL_AFTER_GAP, self.INTERVAL_AFTER_GAP, 1) + + def test_zone_with_gap_start_of_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.TRANSITION_MINUS_5) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_AFTER_GAP, 0) + + def test_zone_with_gap_mid_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.MID_TRANSITION) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_AFTER_GAP, 0) + + def test_zone_with_gap_last_tick_of_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.TRANSITION_PLUS_10.plus_nanoseconds(-1)) + self.check_mapping(mapping, self.INTERVAL_BEFORE_GAP, self.INTERVAL_AFTER_GAP, 0) + + def test_zone_with_gap_first_tick_after_transition(self) -> None: + mapping = self.ZONE_WITH_GAP.map_local(self.TRANSITION_PLUS_10) + self.check_mapping(mapping, self.INTERVAL_AFTER_GAP, self.INTERVAL_AFTER_GAP, 1) + + def test_tricky_case(self) -> None: + """Case added to cover everything: we want our initial guess to hit the + *later* zone, which doesn't actually include the local instant. However, + we want the *earlier* zone to include it. So, we want a zone with two + positive offsets. + """ + # 1am occurs unambiguously in the early zone. + zone = SingleTransitionDateTimeZone(self.TRANSITION, Offset.from_hours(3), Offset.from_hours(5)) + mapping = zone.map_local(LocalDateTime(2000, 1, 1, 1, 0)) + self.check_mapping(mapping, zone.early_interval, zone.early_interval, 1) + + def check_mapping( + self, mapping: ZoneLocalMapping, early_interval: ZoneInterval, late_interval: ZoneInterval, count: int + ) -> None: + assert mapping.early_interval == early_interval + assert mapping.late_interval == late_interval + assert mapping.count == count diff --git a/tests/test_offset.py b/tests/test_offset.py index 7449e8f..2b0856b 100644 --- a/tests/test_offset.py +++ b/tests/test_offset.py @@ -168,9 +168,9 @@ def test_operator_plus_zero_is_neutral_element(self) -> None: def test_operator_plus_non_zero(self) -> None: assert THREE_HOURS + THREE_HOURS == helpers.create_positive_offset(6, 0, 0), "THREE_HOURS + THREE_HOURS" assert THREE_HOURS + NEGATIVE_THREE_HOURS == Offset.zero, "THREE_HOURS + (-THREE_HOURS)" - assert NEGATIVE_TWELVE_HOURS + THREE_HOURS == helpers.create_negative_offset( - 9, 0, 0 - ), "-TWELVE_HOURS + THREE_HOURS" + assert NEGATIVE_TWELVE_HOURS + THREE_HOURS == helpers.create_negative_offset(9, 0, 0), ( + "-TWELVE_HOURS + THREE_HOURS" + ) # Static method equivalents @@ -180,13 +180,13 @@ def test_method_add_zero_is_neutral_element(self) -> None: assert Offset.add(Offset.zero, THREE_HOURS) == helpers.create_positive_offset(3, 0, 0), "0 + THREE_HOURS" def test_method_add_non_zero(self) -> None: - assert Offset.add(THREE_HOURS, THREE_HOURS) == helpers.create_positive_offset( - 6, 0, 0 - ), "THREE_HOURS + THREE_HOURS" + assert Offset.add(THREE_HOURS, THREE_HOURS) == helpers.create_positive_offset(6, 0, 0), ( + "THREE_HOURS + THREE_HOURS" + ) assert Offset.add(THREE_HOURS, NEGATIVE_THREE_HOURS) == Offset.zero, "THREE_HOURS + (-THREE_HOURS)" - assert Offset.add(NEGATIVE_TWELVE_HOURS, THREE_HOURS) == helpers.create_negative_offset( - 9, 0, 0 - ), "-TWELVE_HOURS + THREE_HOURS" + assert Offset.add(NEGATIVE_TWELVE_HOURS, THREE_HOURS) == helpers.create_negative_offset(9, 0, 0), ( + "-TWELVE_HOURS + THREE_HOURS" + ) # Instance method equivalents @@ -198,9 +198,9 @@ def test_method_plus_zero_is_neutral_element(self) -> None: def test_method_plus_non_zero(self) -> None: assert THREE_HOURS.plus(THREE_HOURS) == helpers.create_positive_offset(6, 0, 0), "THREE_HOURS + THREE_HOURS" assert THREE_HOURS.plus(NEGATIVE_THREE_HOURS) == Offset.zero, "THREE_HOURS + (-THREE_HOURS)" - assert NEGATIVE_TWELVE_HOURS.plus(THREE_HOURS) == helpers.create_negative_offset( - 9, 0, 0 - ), "-TWELVE_HOURS + THREE_HOURS" + assert NEGATIVE_TWELVE_HOURS.plus(THREE_HOURS) == helpers.create_negative_offset(9, 0, 0), ( + "-TWELVE_HOURS + THREE_HOURS" + ) # endregion @@ -213,12 +213,12 @@ def test_operator_minus_zero_is_neutral_element(self) -> None: def test_operator_minus_non_zero(self) -> None: assert THREE_HOURS - THREE_HOURS == Offset.zero, "THREE_HOURS - THREE_HOURS" - assert THREE_HOURS - NEGATIVE_THREE_HOURS == helpers.create_positive_offset( - 6, 0, 0 - ), "THREE_HOURS - (-THREE_HOURS)" - assert NEGATIVE_TWELVE_HOURS - THREE_HOURS == helpers.create_negative_offset( - 15, 0, 0 - ), "-TWELVE_HOURS - THREE_HOURS" + assert THREE_HOURS - NEGATIVE_THREE_HOURS == helpers.create_positive_offset(6, 0, 0), ( + "THREE_HOURS - (-THREE_HOURS)" + ) + assert NEGATIVE_TWELVE_HOURS - THREE_HOURS == helpers.create_negative_offset(15, 0, 0), ( + "-TWELVE_HOURS - THREE_HOURS" + ) # Static method equivalents @@ -229,12 +229,12 @@ def test_subtract_zero_is_neutral_element(self) -> None: def test_subtract_non_zero(self) -> None: assert Offset.subtract(THREE_HOURS, THREE_HOURS) == Offset.zero, "THREE_HOURS - THREE_HOURS" - assert Offset.subtract(THREE_HOURS, NEGATIVE_THREE_HOURS) == helpers.create_positive_offset( - 6, 0, 0 - ), "THREE_HOURS - (-THREE_HOURS)" - assert Offset.subtract(NEGATIVE_TWELVE_HOURS, THREE_HOURS) == helpers.create_negative_offset( - 15, 0, 0 - ), "-TWELVE_HOURS - THREE_HOURS" + assert Offset.subtract(THREE_HOURS, NEGATIVE_THREE_HOURS) == helpers.create_positive_offset(6, 0, 0), ( + "THREE_HOURS - (-THREE_HOURS)" + ) + assert Offset.subtract(NEGATIVE_TWELVE_HOURS, THREE_HOURS) == helpers.create_negative_offset(15, 0, 0), ( + "-TWELVE_HOURS - THREE_HOURS" + ) # Instance method equivalents @@ -245,11 +245,11 @@ def test_minus_zero_is_neutral_element(self) -> None: def test_minus_non_zero(self) -> None: assert THREE_HOURS.minus(THREE_HOURS) == Offset.zero, "THREE_HOURS - THREE_HOURS" - assert THREE_HOURS.minus(NEGATIVE_THREE_HOURS) == helpers.create_positive_offset( - 6, 0, 0 - ), "THREE_HOURS - (-THREE_HOURS)" - assert NEGATIVE_TWELVE_HOURS.minus(THREE_HOURS) == helpers.create_negative_offset( - 15, 0, 0 - ), "-TWELVE_HOURS - THREE_HOURS" + assert THREE_HOURS.minus(NEGATIVE_THREE_HOURS) == helpers.create_positive_offset(6, 0, 0), ( + "THREE_HOURS - (-THREE_HOURS)" + ) + assert NEGATIVE_TWELVE_HOURS.minus(THREE_HOURS) == helpers.create_negative_offset(15, 0, 0), ( + "-TWELVE_HOURS - THREE_HOURS" + ) # endregion diff --git a/tests/text/pattern_test_data.py b/tests/text/pattern_test_data.py index 2a5b62e..b31191b 100644 --- a/tests/text/pattern_test_data.py +++ b/tests/text/pattern_test_data.py @@ -111,9 +111,9 @@ def test_parse_failure(self) -> None: assert not result.success, "Expected parsing to fail, but it succeeded" with pytest.raises(UnparsableValueError) as e: result.get_value_or_throw() - assert str(e.value).startswith( - expected_message - ), f"Expected message to start with {expected_message}; was actually {e.value!s}" + assert str(e.value).startswith(expected_message), ( + f"Expected message to start with {expected_message}; was actually {e.value!s}" + ) # TODO: def __repr__() & delete parametrize ids= in tests diff --git a/tests/text/test_local_time_pattern.py b/tests/text/test_local_time_pattern.py index 5397e1d..a4af9a3 100644 --- a/tests/text/test_local_time_pattern.py +++ b/tests/text/test_local_time_pattern.py @@ -639,8 +639,8 @@ def __assert_valid_noda_pattern(cls, culture: CultureInfo, pattern: str) -> None # We'll never do anything "special" with non-ascii characters anyway, # so we don't mind if they're not quoted. elif ord(cursor.current) < ord("\u0080"): - assert ( - cursor.current in EXPECTED_CHARACTERS - ), f"Pattern '{pattern}' contains unquoted, unexpected characters" + assert cursor.current in EXPECTED_CHARACTERS, ( + f"Pattern '{pattern}' contains unquoted, unexpected characters" + ) # Check that the pattern parses LocalTimePattern.create(pattern, culture)