Skip to content

Commit

Permalink
feat: port DurationPattern (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisimcevoy authored May 31, 2024
1 parent d989c2b commit ea5960f
Show file tree
Hide file tree
Showing 8 changed files with 888 additions and 12 deletions.
49 changes: 38 additions & 11 deletions pyoda_time/_duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,28 @@ def one_day(self) -> Duration:
class Duration(metaclass=_DurationMeta):
"""Represents a fixed (and calendar-independent) length of time."""

# TODO: Noda Time's Duration class defines MaxDays as `(1 << 24) - 1` and MinDays as
# `~MaxDays`. However, that range is not sufficiently large for timedelta conversion.
# The thinking here is to retain the flavour of the Noda Time implementation, while
# accommodating the range of the standard way of representing durations in Python.
_MAX_DAYS: Final[int] = (1 << 30) - 1
# Implementation note:
#
# Noda Time's `Duration` far exceeds the range of the equivalent BCL type, namely `TimeSpan`.
# `TimeSpan` has a range of approximately 292 years, whereas `Duration` in Noda Time has a range
# of over 20,000 years.
#
# In Python's standard library, the equivalent type is `datetime.timedelta`, which has a range
# from -86,399,999,913,600,000,000 microseconds to 86,399,999,999,999,999,999 microseconds,
# or about 2.7 billion years! This far exceeds the ranges of both Noda Time's `Duration` and
# the BCL's `TimeSpan`.
#
# It is unlikely that many (if any) users will need Pyoda Time's `Duration` to support a range
# much larger than that of `timedelta`. However, it should at least support conversion to and
# from `timedelta.min` and `timedelta.max`, in the same way that Noda Time's `Duration` supports
# conversion to and from `TimeSpan.MinValue` and `TimeSpan.MaxValue`.
#
# With that in mind, I have tried to retain the defining characteristics of this type from the
# mother project, while extending its range to accommodate the range of `timedelta`. Mostly this
# just involves increasing the "max days" sufficiently, and then dealing with the ramifications
# for DurationPattern[Parser] tests around the extremes...

_MAX_DAYS: Final[int] = (1 << 30) - 1 # In Noda Time, this is `(1 << 24) - 1`
_MIN_DAYS: Final[int] = ~_MAX_DAYS

_MIN_NANOSECONDS: Final[int] = _MIN_DAYS * PyodaConstants.NANOSECONDS_PER_DAY
Expand Down Expand Up @@ -392,7 +409,17 @@ def __hash__(self) -> int:

# region Formatting

# TODO: Duration.ToString() [requires DurationPattern]
def __repr__(self) -> str:
from ._compatibility._culture_info import CultureInfo
from .text import DurationPattern

return DurationPattern._bcl_support.format(self, None, CultureInfo.current_culture)

def __format__(self, format_spec: str) -> str:
from ._compatibility._culture_info import CultureInfo
from .text import DurationPattern

return DurationPattern._bcl_support.format(self, format_spec, CultureInfo.current_culture)

# endregion Formatting

Expand Down Expand Up @@ -819,19 +846,19 @@ def _from_nanoseconds(cls, nanoseconds: decimal.Decimal) -> Duration:
return cls._ctor(days=days, nano_of_day=nano_of_day)

@classmethod
def from_timedelta(cls, time_delta: datetime.timedelta) -> Duration:
def from_timedelta(cls, timedelta: datetime.timedelta) -> Duration:
"""Returns a ``Duration`` that represents the same number of microseconds as the given ``datetime.timedelta``.
:param time_delta: The ``datetime.timedelta`` to convert.
:param timedelta: The ``datetime.timedelta`` to convert.
:return: A new Duration with the same number of microseconds as the given ``datetime.timedelta``.
"""
# Note that we don't use `cls.from_seconds(timedelta.total_seconds())` here.
# That is because `total_seconds()` loses microsecond accuracy for deltas > 270 years.
# https://docs.python.org/3/library/datetime.html#datetime.timedelta.total_seconds
return (
Duration.from_days(time_delta.days)
+ Duration.from_seconds(time_delta.seconds)
+ Duration.from_microseconds(time_delta.microseconds)
Duration.from_days(timedelta.days)
+ Duration.from_seconds(timedelta.seconds)
+ Duration.from_microseconds(timedelta.microseconds)
)

def to_timedelta(self) -> datetime.timedelta:
Expand Down
11 changes: 11 additions & 0 deletions pyoda_time/globalization/_pyoda_format_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ def culture_info(self) -> CultureInfo:
# TODO:
# internal FixedFormatInfoPatternParser<Duration> DurationPatternParser

@property
def _duration_pattern_parser(self) -> _FixedFormatInfoPatternParser[Duration]:
if self.__duration_pattern_parser is None:
with self.__FIELD_LOCK:
if self.__duration_pattern_parser is None:
from ..text._duration_pattern_parser import _DurationPatternParser
from ..text._fixed_format_info_pattern_parser import _FixedFormatInfoPatternParser

self.__duration_pattern_parser = _FixedFormatInfoPatternParser(_DurationPatternParser(), self)
return self.__duration_pattern_parser

@property
def _offset_pattern_parser(self) -> _FixedFormatInfoPatternParser[Offset]:
if self.__offset_pattern_parser is None:
Expand Down
2 changes: 2 additions & 0 deletions pyoda_time/text/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
__all__: list[str] = [
"patterns",
"AnnualDatePattern",
"DurationPattern",
"InstantPattern",
"InvalidPatternError",
"LocalDatePattern",
Expand All @@ -16,6 +17,7 @@
]

from ._annual_date_pattern import AnnualDatePattern
from ._duration_pattern import DurationPattern
from ._instant_pattern import InstantPattern
from ._invalid_pattern_exception import InvalidPatternError
from ._local_date_pattern import LocalDatePattern
Expand Down
183 changes: 183 additions & 0 deletions pyoda_time/text/_duration_pattern.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# 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 _ProtocolMeta, final

from pyoda_time._compatibility._culture_info import CultureInfo
from pyoda_time._compatibility._string_builder import StringBuilder
from pyoda_time._duration import Duration
from pyoda_time.globalization._pyoda_format_info import _PyodaFormatInfo
from pyoda_time.text._i_pattern import IPattern
from pyoda_time.text._parse_result import ParseResult
from pyoda_time.text.patterns._pattern_bcl_support import _PatternBclSupport
from pyoda_time.utility._csharp_compatibility import _private, _sealed
from pyoda_time.utility._preconditions import _Preconditions


class __DurationPatternMeta(_ProtocolMeta):
__pattern_bcl_support: _PatternBclSupport[Duration] | None = None

@property
def roundtrip(self) -> DurationPattern:
"""Gets the general pattern for durations using the invariant culture, with a format string of
"-D:hh:mm:ss.FFFFFFFFF".
This pattern round-trips. This corresponds to the "o" standard pattern.
:return: The general pattern for durations using the invariant culture.
"""
return DurationPattern._Patterns._roundtrip_pattern_impl

@property
def json_roundtrip(self) -> DurationPattern:
"""Gets a pattern for durations using the invariant culture, with a format string of "-H:mm:ss.FFFFFFFFF".
This pattern round-trips. This corresponds to the "j" standard pattern.
:return: The pattern for durations using the invariant culture.
"""
return DurationPattern._Patterns._json_roundtrip_pattern_impl

@property
def _bcl_support(self) -> _PatternBclSupport[Duration]:
if self.__pattern_bcl_support is None:
self.__pattern_bcl_support = _PatternBclSupport("o", lambda fi: fi._duration_pattern_parser)
return self.__pattern_bcl_support


@final
@_sealed
@_private
class DurationPattern(IPattern[Duration], metaclass=__DurationPatternMeta):
"""Represents a pattern for parsing and formatting ``Duration`` values."""

class __PatternsMeta(type):
__roundtrip_pattern_impl: DurationPattern | None = None
__json_roundtrip_pattern_impl: DurationPattern | None = None

@property
def _roundtrip_pattern_impl(self) -> DurationPattern:
if self.__roundtrip_pattern_impl is None:
self.__roundtrip_pattern_impl = DurationPattern.create_with_invariant_culture("-D:hh:mm:ss.FFFFFFFFF")
return self.__roundtrip_pattern_impl

@property
def _json_roundtrip_pattern_impl(self) -> DurationPattern:
if self.__json_roundtrip_pattern_impl is None:
self.__json_roundtrip_pattern_impl = DurationPattern.create_with_invariant_culture("-H:mm:ss.FFFFFFFFF")
return self.__json_roundtrip_pattern_impl

class _Patterns(metaclass=__PatternsMeta):
pass

__pattern: IPattern[Duration]
__pattern_text: str

@property
def pattern_text(self) -> str:
"""Gets the pattern text for this pattern, as supplied on creation.
:return: The pattern text for this pattern, as supplied on creation.
"""
return self.__pattern_text

@classmethod
def __ctor(cls, pattern_text: str, pattern: IPattern[Duration]) -> DurationPattern:
self = super().__new__(cls)
self.__pattern_text = pattern_text
self.__pattern = pattern
return self

def parse(self, text: str) -> ParseResult[Duration]:
"""Parses the given text value according to the rules of this pattern.
This method never throws an exception (barring a bug in Pyoda Time itself). Even errors such as the argument
being null are wrapped in a parse result.
:param text: The text value to parse.
:return: The result of parsing, which may be successful or unsuccessful.
"""
return self.__pattern.parse(text)

def format(self, value: Duration) -> str:
"""Formats the given duration as text according to the rules of this pattern.
:param value: The duration to format.
:return: The duration formatted according to this pattern.
"""
return self.__pattern.format(value)

def append_format(self, value: Duration, builder: StringBuilder) -> StringBuilder:
"""Formats the given value as text according to the rules of this pattern, appending to the given
``StringBuilder``.
:param value: The value to format.
:param builder: The ``StringBuilder`` to append to.
:return: The builder passed in as ``builder``.
"""
return self.__pattern.append_format(value, builder)

@classmethod
def __create(cls, pattern_text: str, format_info: _PyodaFormatInfo) -> DurationPattern:
"""Creates a pattern for the given pattern text and format info.
:param pattern_text: Pattern text to create the pattern for
:param format_info: Localization information
:return: A pattern for parsing and formatting offsets.
:raises InvalidPatternError: The pattern text was invalid.
"""
_Preconditions._check_not_null(pattern_text, "pattern_text")
_Preconditions._check_not_null(format_info, "format_info")
pattern = format_info._duration_pattern_parser._parse_pattern(pattern_text)
return DurationPattern.__ctor(pattern_text, pattern)

@classmethod
def create(cls, pattern_text: str, culture_info: CultureInfo) -> DurationPattern:
"""Creates a pattern for the given pattern text and culture.
See the user guide for the available pattern text options.
:param pattern_text: Pattern text to create the pattern for
:param culture_info: The culture to use in the pattern
:return: A pattern for parsing and formatting offsets.
:raises InvalidPatternError: The pattern text was invalid.
"""
return cls.__create(pattern_text, _PyodaFormatInfo._get_format_info(culture_info))

@classmethod
def create_with_current_culture(cls, pattern_text: str) -> DurationPattern:
"""Creates a pattern for the given pattern text in the current thread's current culture.
See the user guide for the available pattern text options. Note that the current culture
is captured at the time this method is called - it is not captured at the point of parsing
or formatting values.
:param pattern_text: Pattern text to create the pattern for
:return: A pattern for parsing and formatting offsets.
:raises InvalidPatternError: The pattern text was invalid.
"""
return cls.__create(pattern_text, _PyodaFormatInfo.current_info)

@classmethod
def create_with_invariant_culture(cls, pattern_text: str) -> DurationPattern:
"""Creates a pattern for the given pattern text in the invariant culture.
See the user guide for the available pattern text options. Note that the current culture
is captured at the time this method is called - it is not captured at the point of parsing
or formatting values.
:param pattern_text: Pattern text to create the pattern for
:return: A pattern for parsing and formatting offsets.
:raises InvalidPatternError: The pattern text was invalid.
"""
return cls.__create(pattern_text, _PyodaFormatInfo.invariant_info)

def with_culture(self, culture_info: CultureInfo) -> DurationPattern:
"""Creates a pattern for the same original pattern text as this pattern, but with the specified culture.
:param culture_info: The culture to use in the new pattern.
:return: A new pattern with the given culture.
"""
return self.__create(self.pattern_text, _PyodaFormatInfo._get_format_info(culture_info))
Loading

0 comments on commit ea5960f

Please sign in to comment.