Skip to content

Commit

Permalink
feat: implement LocalTime operators (#127)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisimcevoy authored May 23, 2024
1 parent 4e736f6 commit f23df54
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 4 deletions.
98 changes: 97 additions & 1 deletion pyoda_time/_local_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,50 @@ def nanosecond_of_day(self) -> int:
"""The nanosecond of this local time within the day, in the range 0 to 86,399,999,999,999 inclusive."""
return self.__nanoseconds

def __add__(self, other: Period) -> LocalTime:
"""Creates a new local time by adding a period to an existing time.
The period must not contain any date-related units (days etc.) with non-zero values.
:param other: The period to add
:return: The result of adding the period to the time, wrapping via midnight if necessary
"""
from ._period import Period

if not isinstance(other, Period):
return NotImplemented # type: ignore[unreachable]

_Preconditions._check_not_null(other, "other")
_Preconditions._check_argument(
not other.has_date_component, "other", "Cannot add a period with a date component to a time"
)
return (
self.plus_hours(other.hours)
.plus_minutes(other.minutes)
.plus_seconds(other.seconds)
.plus_milliseconds(other.milliseconds)
.plus_ticks(other.ticks)
.plus_nanoseconds(other.nanoseconds)
)

@staticmethod
def add(time: LocalTime, period: Period) -> LocalTime:
"""Adds the specified period to the time. Friendly alternative to ``+``.
:param time: The time to add the period to
:param period: The period to add. Must not contain any (non-zero) date units.
:return: The sum of the given time and period
"""
return time + period

def plus(self, period: Period) -> LocalTime:
"""Adds the specified period to this time. Fluent alternative to ``+``.
:param period: The period to add. Must not contain any (non-zero) date units.
:return: The sum of this time and the given period
"""
return self + period

@overload
def __sub__(self, local_time: LocalTime) -> Period: ...

Expand All @@ -393,10 +437,60 @@ def __sub__(self, other: LocalTime | Period) -> LocalTime | Period:
)

if isinstance(other, LocalTime):
return Period.between(self, other)
return Period.between(other, self)

return NotImplemented # type: ignore[unreachable]

@staticmethod
@overload
def subtract(time: LocalTime, period: Period, /) -> LocalTime:
"""Subtracts the specified period from the time. Friendly alternative to ``-``.
:param time: The time to subtract the period from
:param period: The period to subtract. Must not contain any (non-zero) date units.
:return: The result of subtracting the given period from the time.
"""
...

@staticmethod
@overload
def subtract(lhs: LocalTime, rhs: LocalTime, /) -> Period:
"""Subtracts one time from another, returning the result as a ``Period`` with units of years, months and days.
This is simply a convenience method for calling ``Period.between(LocalTime,LocalTime)``.
:param lhs: The time to subtract from
:param rhs: The time to subtract
:return: The result of subtracting one time from another.
"""
...

@staticmethod
def subtract(lhs: LocalTime, rhs: LocalTime | Period, /) -> LocalTime | Period:
return lhs - rhs

@overload
def minus(self, period: Period, /) -> LocalTime:
"""Subtracts the specified period from this time. Fluent alternative to ``-``.
:param period: The period to subtract. Must not contain any (non-zero) date units.
:return: The result of subtracting the given period from this time.
"""
...

@overload
def minus(self, time: LocalTime, /) -> Period:
"""Subtracts the specified time from this time, returning the result as a ``Period``. Fluent alternative to
``-``.
:param time: The time to subtract from this
:return: The difference between the specified time and this one
"""
...

def minus(self, period_or_time: Period | LocalTime, /) -> Period | LocalTime:
return self - period_or_time

def __eq__(self, other: object) -> bool:
if not isinstance(other, LocalTime):
return NotImplemented
Expand Down Expand Up @@ -430,6 +524,8 @@ def __ge__(self, other: LocalTime) -> bool:
def compare_to(self, other: LocalTime | None) -> int:
if other is None:
return 1
if not isinstance(other, LocalTime):
raise TypeError(f"{self.__class__.__name__} cannot be compared to {other.__class__.__name__}")
return self.__nanoseconds - other.__nanoseconds

def plus_hours(self, hours: int) -> LocalTime:
Expand Down
3 changes: 2 additions & 1 deletion pyoda_time/_year_month.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ def compare_to(self, other: YearMonth | None) -> int:
"""
if other is None:
return 1
_Preconditions._check_argument(isinstance(other, YearMonth), "other", "Object must be of type YearMonth.")
if not isinstance(other, YearMonth):
raise TypeError(f"{self.__class__.__name__} cannot be compared to {other.__class__.__name__}")
_Preconditions._check_argument(
self.__calendar_ordinal == other.__calendar_ordinal,
"other",
Expand Down
3 changes: 2 additions & 1 deletion pyoda_time/_year_month_day.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ def _with_calendar_ordinal(self, calendar_ordinal: _CalendarOrdinal) -> _YearMon
def compare_to(self, other: _YearMonthDay | None) -> int:
if other is None:
return 1
# In Noda Time, this method calls `int.CompareTo(otherInt)`
if not isinstance(other, _YearMonthDay):
raise TypeError(f"{self.__class__.__name__} cannot be compared to {other.__class__.__name__}")
return self.__value - other.__value

def equals(self, other: _YearMonthDay) -> bool:
Expand Down
130 changes: 129 additions & 1 deletion tests/test_local_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# Use of this source code is governed by the Apache License 2.0,
# as found in the LICENSE.txt file.
from datetime import datetime, timedelta
from typing import cast

import pytest

from pyoda_time import LocalTime, Offset, OffsetTime, Period, PyodaConstants
from pyoda_time import CalendarSystem, LocalDate, LocalDateTime, LocalTime, Offset, OffsetTime, Period, PyodaConstants


class TestLocalTime:
Expand Down Expand Up @@ -270,3 +271,130 @@ def test_from_time(self) -> None:
expected = LocalTime(12, 34, 56).plus_microseconds(4567)
actual = LocalTime.from_time(time_)
assert actual == expected


class TestLocalTimeOperators:
def test_addition_with_period(self) -> None:
start = LocalTime(3, 30)
period = Period.from_hours(2) + Period.from_seconds(1)
expected = LocalTime(5, 30, 1)
assert start + period == expected

def test_addition_wraps_at_midnight(self) -> None:
start = LocalTime(22, 0)
period = Period.from_hours(3)
expected = LocalTime(1, 0)
assert start + period == expected

def test_addition_with_null_period_raises_type_error(self) -> None:
start = LocalTime(12, 0)
with pytest.raises(TypeError):
start + cast(Period, None)

def test_subtraction_with_period(self) -> None:
start = LocalTime(5, 30, 1)
period = Period.from_hours(2) + Period.from_seconds(1)
expected = LocalTime(3, 30, 0)
assert start - period == expected

def test_subtraction_wraps_at_midnight(self) -> None:
start = LocalTime(1, 0, 0)
period = Period.from_hours(3)
expected = LocalTime(22, 0, 0)
assert start - period == expected

def test_subtraction_with_null_period_raises_type_error(self) -> None:
start = LocalTime(12, 0)
with pytest.raises(TypeError):
start - cast(Period, None)

def test_addition_period_with_date(self) -> None:
time = LocalTime(20, 30)
period = Period.from_days(1)
with pytest.raises(ValueError):
LocalTime.add(time, period)

def test_subtraction_period_with_time(self) -> None:
time = LocalTime(20, 30)
period = Period.from_days(1)
with pytest.raises(ValueError):
LocalTime.subtract(time, period)

def test_period_addition_method_equivalents(self) -> None:
start = LocalTime(20, 30)
period = Period.from_hours(3) + Period.from_minutes(10)
assert LocalTime.add(start, period) == start + period
assert start.plus(period) == start + period

def test_period_subtraction_method_equivalents(self) -> None:
start = LocalTime(20, 30)
period = Period.from_hours(3) + Period.from_minutes(10)
end = start + period
assert LocalTime.subtract(start, period) == start - period
assert start.minus(period) == start - period

assert end - start == period
assert LocalTime.subtract(end, start) == period
assert end.minus(start) == period

def test_comparison_operators(self) -> None:
time_1 = LocalTime(10, 30, 45)
time_2 = LocalTime(10, 30, 45)
time_3 = LocalTime(10, 30, 50)

assert time_1 == time_2
assert not time_1 == time_3
assert not time_1 != time_2
assert time_1 != time_3

assert not time_1 < time_2
assert time_1 < time_3
assert not time_2 < time_1
assert not time_3 < time_1

assert time_1 <= time_2
assert time_1 <= time_3
assert time_2 <= time_1
assert not time_3 <= time_1

assert not time_1 > time_2
assert not time_1 > time_3
assert not time_2 > time_1

assert time_1 >= time_2
assert not time_1 >= time_3
assert time_2 >= time_1
assert time_3 >= time_1

def test_comparison_ignores_original_calendar(self) -> None:
date_time_1 = LocalDateTime(1900, 1, 1, 10, 30, 0)
date_time_2 = date_time_1.with_calendar(CalendarSystem.julian)

# Calendar information is propagated into LocalDate, but not into LocalTime
assert not date_time_1.date == date_time_2.date
assert date_time_1.time_of_day == date_time_2.time_of_day

def test_compare_to(self) -> None:
time_1 = LocalTime(10, 30, 45)
time_2 = LocalTime(10, 30, 45)
time_3 = LocalTime(10, 30, 50)

assert time_1.compare_to(time_2) == 0
assert time_1.compare_to(time_3) < 0
assert time_3.compare_to(time_2) > 0

# TODO: This one is redundant in pyoda time:
# `public void IComparableCompareTo()`

def test_i_comparable_to_null_positive(self) -> None:
instance = LocalTime(10, 30, 45)
result = instance.compare_to(None)
assert result > 0

def test_i_comparable_compare_to_wrong_type_argument_exception(self) -> None:
# TODO: This is necessarily different to Noda Time, but still worth doing.
instance = LocalTime(10, 30, 45)
arg = LocalDate(2012, 3, 6)
with pytest.raises(TypeError) as e:
instance.compare_to(arg) # type: ignore
assert str(e.value) == "LocalTime cannot be compared to LocalDate"

0 comments on commit f23df54

Please sign in to comment.