Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Implement DateTimeZone #243

Merged
merged 3 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion pyoda_time/_calendar_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
6 changes: 3 additions & 3 deletions pyoda_time/_compatibility/_culture_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pyoda_time/_compatibility/_date_time_format_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
134 changes: 132 additions & 2 deletions pyoda_time/_date_time_zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,6 +205,92 @@

# 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.")

Check warning on line 243 in pyoda_time/_date_time_zone.py

View check run for this annotation

Codecov / codecov/patch

pyoda_time/_date_time_zone.py#L242-L243

Added lines #L242 - L243 were not covered by tests

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(
Expand Down Expand Up @@ -287,3 +381,39 @@
_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")

Check warning on line 419 in pyoda_time/_date_time_zone.py

View check run for this annotation

Codecov / codecov/patch

pyoda_time/_date_time_zone.py#L419

Added line #L419 was not covered by tests
39 changes: 29 additions & 10 deletions pyoda_time/_instant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
16 changes: 15 additions & 1 deletion pyoda_time/_local_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
63 changes: 62 additions & 1 deletion pyoda_time/_offset_date_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -92,6 +93,66 @@
"""
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

Check warning on line 130 in pyoda_time/_offset_date_time.py

View check run for this annotation

Codecov / codecov/patch

pyoda_time/_offset_date_time.py#L130

Added line #L130 was not covered by tests

@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
Expand Down
2 changes: 1 addition & 1 deletion pyoda_time/_skipped_time_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading