Skip to content

Commit

Permalink
feat: baseline TzdbDateTimeZoneSource functionality (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisimcevoy authored May 28, 2024
1 parent 8e3326c commit 7e05fd3
Show file tree
Hide file tree
Showing 14 changed files with 1,170 additions and 11 deletions.
4 changes: 4 additions & 0 deletions pyoda_time/_date_time_zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class DateTimeZone(abc.ABC, _IZoneIntervalMap, metaclass=_DateTimeZoneMeta):

_UTC_ID: Final[str] = "UTC"

@classmethod
def for_offset(cls, offset: Offset) -> DateTimeZone:
raise NotImplementedError

def __init__(self, id_: str, is_fixed: bool, min_offset: Offset, max_offset: Offset) -> None:
"""Initializes a new instance of the DateTimeZone class.
Expand Down
Binary file added pyoda_time/time_zones/Tzdb.nzd
Binary file not shown.
57 changes: 57 additions & 0 deletions pyoda_time/time_zones/_cached_date_time_zone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright 2024 The Pyoda Time Authors. All rights reserved.
# Use of this source code is governed by the Apache License 2.0,
# as found in the LICENSE.txt file.
from __future__ import annotations

from typing import final

from pyoda_time import DateTimeZone, Instant
from pyoda_time.time_zones import ZoneInterval
from pyoda_time.time_zones._caching_zone_interval_map import _CachingZoneIntervalMap
from pyoda_time.time_zones._i_zone_interval_map import _IZoneIntervalMap
from pyoda_time.utility._csharp_compatibility import _private, _sealed
from pyoda_time.utility._preconditions import _Preconditions


@final
@_sealed
@_private
class _CachedDateTimeZone(DateTimeZone):
"""Provides a ``DateTimeZone`` wrapper class that implements a simple cache to speed up the lookup of
transitions."""

__map: _IZoneIntervalMap
__time_zone: DateTimeZone

@property
def _time_zone(self) -> DateTimeZone:
"""Gets the cached time zone.
:return: The time zone.
"""
return self.__time_zone

@classmethod
def __ctor(cls, time_zone: DateTimeZone, map: _IZoneIntervalMap) -> _CachedDateTimeZone:
"""Initializes a new instance of the ``_CachedDateTimeZone`` class.
:param time_zone: The time zone to cache.
:param map: The caching map
:return: The new instance of the ``_CachedDateTimeZone`` class.
"""
self = super().__new__(cls)
super(_CachedDateTimeZone, self).__init__(time_zone.id, False, time_zone.min_offset, time_zone.max_offset)
self.__time_zone = time_zone
self.__map = map
return self

@classmethod
def _for_zone(cls, time_zone: DateTimeZone) -> DateTimeZone:
_Preconditions._check_not_null(time_zone, "time_zone")
if isinstance(time_zone, _CachedDateTimeZone) or time_zone._is_fixed:
return time_zone
return cls.__ctor(time_zone, _CachingZoneIntervalMap._cache_map(time_zone))

def get_zone_interval(self, instant: Instant) -> ZoneInterval:
"""Delegates fetching a zone interval to the caching map."""
return self.__map.get_zone_interval(instant)
161 changes: 161 additions & 0 deletions pyoda_time/time_zones/_caching_zone_interval_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Copyright 2024 The Pyoda Time Authors. All rights reserved.
# Use of this source code is governed by the Apache License 2.0,
# as found in the LICENSE.txt file.
from __future__ import annotations

from typing import Self, final

from pyoda_time import Duration, Instant, Offset
from pyoda_time.time_zones import ZoneInterval
from pyoda_time.time_zones._i_zone_interval_map import _IZoneIntervalMap
from pyoda_time.utility._csharp_compatibility import _private, _sealed
from pyoda_time.utility._preconditions import _Preconditions

_PERIOD_SHIFT: int = 5
"""Defines the number of bits to shift an instant's "days since epoch" to get the period.
This converts an instant into a number of 32 day periods.
"""


@final
@_sealed
@_private
class _CachingZoneIntervalMap:
"""Helper methods for creating IZoneIntervalMaps which cache results."""

@classmethod
def _cache_map(cls, map_: _IZoneIntervalMap) -> _IZoneIntervalMap:
"""Returns a caching map for the given input map."""
return cls.__HashArrayCache._ctor(map_)

# region Nested type: HashArrayCache

@final
@_sealed
@_private
class __HashArrayCache(_IZoneIntervalMap):
"""This provides a simple cache based on two hash tables (one for local instants, another for instants).
Each hash table entry is either entry or contains a node with enough
information for a particular "period" of 32 days - so multiple calls for time
zone information within the same few years are likely to hit the cache. Note that
a single "period" may include a daylight saving change (or conceivably more than one);
a node therefore has to contain enough intervals to completely represent that period.
If another call is made which maps to the same cache entry number but is for a different
period, the existing hash entry is simply overridden.
"""

# region Nested type: _HashCacheNode

@final
@_sealed
@_private
class _HashCacheNode:
__interval: ZoneInterval
__period: int
__previous: Self | None

@property
def _interval(self) -> ZoneInterval:
return self.__interval

@property
def _period(self) -> int:
return self.__period

@property
def _previous(self) -> Self | None:
return self.__previous

@classmethod
def _create_node(cls, period: int, map_: _IZoneIntervalMap) -> Self:
days = period << _PERIOD_SHIFT
period_start = Instant._from_untrusted_duration(
Duration._ctor(days=max(days, Instant._MIN_DAYS), nano_of_day=0)
)
next_period_start_days = days + (1 << _PERIOD_SHIFT)

interval = map_.get_zone_interval(period_start)
node = cls.__ctor(interval, period, None)

# Keep going while the current interval ends before the period.
# (We only need to check the days, as every period lands on a
# day boundary.)
# If the raw end is the end of time, the condition will definitely
# evaluate to false.
while interval._raw_end._days_since_epoch < next_period_start_days:
interval = map_.get_zone_interval(interval.end)
node = cls.__ctor(interval, period, node)
return node

@classmethod
def __ctor(cls, interval: ZoneInterval, period: int, previous: Self | None) -> Self:
"""Initializes a new instance of the ``_HashCacheNode`` class.
:param interval: The zone interval.
:param period:
:param previous: The previous ``_HashCacheNode`` node.
:return: The new instance of the ``_HashCacheNode`` class.
"""
self = super().__new__(cls)
self.__period = period
self.__interval = interval
self.__previous = previous
return self

# endregion

__CACHE_SIZE: int = 512
"""Currently we have no need or way to create hash cache zones with different cache sizes.
But the cache size should always be a power of 2 to get the "period to cache entry" conversion simply as a
bitmask operation.
"""

__CACHE_PERIOD_MASK: int = __CACHE_SIZE - 1
"""Mask to AND the period number with in order to get the cache entry index.
The result will always be in the range [0, CacheSize).
"""

@property
def min_offset(self) -> Offset:
return self.__map.min_offset

@property
def max_offset(self) -> Offset:
return self.__map.max_offset

__instant_cache: list[_HashCacheNode | None]
__map: _IZoneIntervalMap

@classmethod
def _ctor(cls, map_: _IZoneIntervalMap) -> Self:
self = super().__new__(cls)
self.__map = _Preconditions._check_not_null(map_, "map_")
self.__instant_cache = [None] * cls.__CACHE_SIZE
return self

def get_zone_interval(self, instant: Instant) -> ZoneInterval:
"""Gets the zone offset period for the given instant. Null is returned if no period is defined by the time
zone for the given instant.
:param instant: The Instant to test.
:return: The defined ZoneOffsetPeriod or null.
"""
period = instant._days_since_epoch >> _PERIOD_SHIFT
index = period & self.__CACHE_PERIOD_MASK
node = self.__instant_cache[index]
if (node is None) or (node._period != period):
node = self._HashCacheNode._create_node(period, self.__map)
self.__instant_cache[index] = node

# Note: moving this code into an instance method in HashCacheNode makes a surprisingly
# large performance difference.
while node._previous is not None and node._interval._raw_start > instant:
node = node._previous
return node._interval

# endregion
70 changes: 67 additions & 3 deletions pyoda_time/time_zones/_fixed_date_time_zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

from typing import TYPE_CHECKING, final

from ..utility._hash_code_helper import _hash_code_helper
from ..utility._preconditions import _Preconditions
from .io._i_date_time_zone_reader import _IDateTimeZoneReader

if TYPE_CHECKING:
from .. import Instant, Offset
from . import ZoneInterval
Expand Down Expand Up @@ -52,12 +56,72 @@ def _get_fixed_zone_or_null(cls, id_: str) -> DateTimeZone | None:
parse_result: ParseResult[Offset] = OffsetPattern.general_invariant.parse(id_[len(cls._UTC_ID) :])
return cls.for_offset(parse_result.value) if parse_result.success else None

@classmethod
def for_offset(cls, offset: Offset) -> DateTimeZone:
raise NotImplementedError
@property
def offset(self) -> Offset:
"""Returns the fixed offset for this time zone.
:return: The fixed offset for this time zone.
"""
return self.max_offset

@property
def name(self) -> str:
"""Returns the name used for the zone interval for this time zone.
:return: The name used for the zone interval for this time zone.
"""
return self.__interval.name

def get_zone_interval(self, instant: Instant) -> ZoneInterval:
return self.__interval

def get_utc_offset(self, instant: Instant) -> Offset:
return self.max_offset

@classmethod
def read(cls, reader: _IDateTimeZoneReader, id_: str) -> DateTimeZone:
"""Reads a fixed time zone from the specified reader.
:param reader: The reader.
:param id_: The id.
:return: The fixed time zone.
"""
_Preconditions._check_not_null(reader, "reader")
_Preconditions._check_not_null(id_, "id_")
offset = reader.read_offset()
name = reader.read_string() if reader.has_more_data else id_
return cls(id_=id_, offset=offset, name=name)

def equals(self, other: _FixedDateTimeZone) -> bool:
"""Indicates whether this instance and another instance are equal.
:param other: Another instance to compare to.
:return: True if the specified value is a ``_FixedDateTimeZone`` with the same name, ID and offset; otherwise,
false.
"""
return self == other

def __eq__(self, other: object) -> bool:
"""Indicates whether this instance and another instance are equal.
:param other: Another instance to compare to.
:return: True if the specified value is a ``_FixedDateTimeZone`` with the same name, ID and offset; otherwise,
false.
"""
if not isinstance(other, _FixedDateTimeZone):
return NotImplemented
return self.offset == other.offset and self.id == other.id and self.name == other.name

def __hash__(self) -> int:
"""Computes the hash code for this instance.
:return: An integer that is the hash code for this instance.
"""
return _hash_code_helper(self.offset, self.id, self.name)

def __repr__(self) -> str:
"""Returns a string that represents this instance.
:return: A string that represents this instance.
"""
return self.id
Loading

0 comments on commit 7e05fd3

Please sign in to comment.