diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 82584d5..2c387cd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -37,7 +37,7 @@ jobs:
coverage: false
name: ${{ matrix.os }} py-${{ matrix.python-version }} ${{ matrix.tz }} ${{ matrix.coverage && '(coverage)' || '' }}
env:
- PYTEST_ADDOPTS: -n 5 -m 'slow or not slow'
+ PYTEST_ADDOPTS: -n 5 -m 'slow or not slow' --color=yes
steps:
- name: Checkout repo
uses: actions/checkout@v4
diff --git a/CHANGES.md b/CHANGES.md
index bd25689..7575c0b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -8,7 +8,16 @@ creating a new release entry be sure to copy & paste the span tag with the
`actions:bind` attribute, which is used by a regex to find the text to be
updated. Only the first match gets replaced, so it's fine to leave the old
ones in. -->
---------------------------------------------------------------------------------
+## isodatetime 3.2.0 (Upcoming)
+
+### Breaking changes
+
+[#234](https://github.com/metomi/isodatetime/pull/234):
+Fixed behaviour of adding a truncated TimePoint to a normal TimePoint.
+
+[#203](https://github.com/metomi/isodatetime/pull/203):
+`TimePoint.seconds_since_unix_epoch` is now an `int` instead of `str`.
+
## isodatetime 3.1.0 (Released 2023-10-05)
@@ -19,7 +28,6 @@ Requires Python 3.7+
[#231](https://github.com/metomi/isodatetime/pull/231):
Fixed mistakes in the CLI help text.
---------------------------------------------------------------------------------
## isodatetime 3.0.0 (Released 2022-03-31)
@@ -44,7 +52,6 @@ TimePoints.
Fixed a bug where the `timezone` functions would return incorrect results
for certain non-standard/unusual system time zones.
---------------------------------------------------------------------------------
## isodatetime 2.0.2 (Released 2020-07-01)
@@ -62,7 +69,6 @@ CLI can now read in from piped stdin.
TimePoints can no longer be created with out-of-bounds values, e.g.
`2020-00-00`, `2020-13-32T25:60`, `--02-30` are not valid.
---------------------------------------------------------------------------------
## isodatetime 2.0.1 (Released 2019-07-23)
@@ -86,7 +92,6 @@ Support the CF compatible calendar mode strings `360_day`, `365_day` & `366_day`
[#132](https://github.com/metomi/isodatetime/pull/132):
Change namespace of `isodatetime` to `metomi.isodatetime`
---------------------------------------------------------------------------------
## isodatetime 2.0.0 (Released 2019-01-22)
@@ -119,7 +124,6 @@ Fixed time point dumper time zone inconsistency.
[#118](https://github.com/metomi/isodatetime/pull/118):
Fixed time point dumper date type inconsistency.
---------------------------------------------------------------------------------
## isodatetime 2018.11.0 (Released 2018-11-05)
@@ -143,7 +147,6 @@ Fix for timezone offsets where minutes are not 0.
[#87](https://github.com/metomi/isodatetime/pull/87):
Add `setup.py`.
---------------------------------------------------------------------------------
## isodatetime 2018.09.0 (Released 2018-09-11)
@@ -155,7 +158,6 @@ This is the 10th release of isodatetime.
New TimePoint method to find the next smallest property that is missing from a
truncated representation.
---------------------------------------------------------------------------------
## isodatetime 2018.02.0 (Released 2018-02-06)
@@ -166,7 +168,6 @@ This is the 9th release of isodatetime.
[#82](https://github.com/metomi/isodatetime/pull/82):
Fix subtracting a later timepoint from an earlier one.
---------------------------------------------------------------------------------
## isodatetime 2017.08.0 (Released 2017-08-09)
@@ -180,13 +181,11 @@ Fix error string for bad conversion for strftime/strptime.
[#74](https://github.com/metomi/isodatetime/pull/74):
Slotted the data classes to improve memory footprint.
---------------------------------------------------------------------------------
## isodatetime 2017.02.1 (Released 2017-02-21)
This is the 7th release of isodatetime. Admin only release.
---------------------------------------------------------------------------------
## isodatetime 2017.02.0 (Released 2017-02-20)
@@ -197,7 +196,6 @@ This is the 6th release of isodatetime.
[#73](https://github.com/metomi/isodatetime/pull/73):
Fix adding duration not in weeks and duration in weeks.
---------------------------------------------------------------------------------
## isodatetime 2014.10.0 (Released 2014-10-01)
@@ -216,7 +214,6 @@ Fix `date1 - date2` where `date2` is greater than `date1` and `date1` and
[#60](https://github.com/metomi/isodatetime/pull/60):
Stricter dumper year bounds checking.
---------------------------------------------------------------------------------
## isodatetime 2014.08.0 (Released 2014-08-11)
@@ -235,7 +232,6 @@ digits.
Speeds up calculations involving counting the days over a number of consecutive
years.
---------------------------------------------------------------------------------
## isodatetime 2014.07.0 (Released 2014-07-29)
@@ -253,7 +249,6 @@ More flexible API for calendar mode.
[#48](https://github.com/metomi/isodatetime/pull/48):
`TimeInterval` class: add `get_seconds` method and input prettifying.
---------------------------------------------------------------------------------
## isodatetime 2014.06.0 (Released 2014-06-19)
@@ -279,7 +274,6 @@ Implement subset of strftime/strptime POSIX standard.
[#28](https://github.com/metomi/isodatetime/pull/28):
Fix get next point for single-repetition recurrences.
---------------------------------------------------------------------------------
## isodatetime 2014-03 (Released 2014-03-13)
diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py
index ed42c6d..faf4771 100644
--- a/metomi/isodatetime/data.py
+++ b/metomi/isodatetime/data.py
@@ -19,14 +19,15 @@
"""This provides ISO 8601 data model functionality."""
+from functools import lru_cache
+from math import floor
+import operator
+from typing import Dict, List, Optional, Tuple, Union, cast, overload
+
from . import dumpers
from . import timezone
from .exceptions import BadInputError
-import operator
-from functools import lru_cache
-from math import floor
-
_operator_map = {op.__name__: op for op in [
operator.eq, operator.lt, operator.le, operator.gt, operator.ge]}
@@ -277,7 +278,7 @@ def max_point(self): return self._max_point
@property
def format_number(self): return self._format_number
- def get_is_valid(self, timepoint: "TimePoint") -> bool:
+ def get_is_valid(self, timepoint: 'TimePoint') -> bool:
"""Return whether the timepoint is valid for this recurrence."""
if not self._get_is_in_bounds(timepoint):
return False
@@ -290,7 +291,7 @@ def get_is_valid(self, timepoint: "TimePoint") -> bool:
return False
return False
- def get_next(self, timepoint: "TimePoint") -> "TimePoint":
+ def get_next(self, timepoint: 'TimePoint') -> Optional['TimePoint']:
"""Return the next timepoint after this timepoint in the recurrence
series, or None."""
if self._repetitions == 1 or timepoint is None:
@@ -300,7 +301,7 @@ def get_next(self, timepoint: "TimePoint") -> "TimePoint":
return next_timepoint
return None
- def get_prev(self, timepoint: "TimePoint") -> "TimePoint":
+ def get_prev(self, timepoint: 'TimePoint') -> Optional['TimePoint']:
"""Return the previous timepoint before this timepoint in the
recurrence series, or None."""
if self._repetitions == 1 or timepoint is None:
@@ -310,7 +311,7 @@ def get_prev(self, timepoint: "TimePoint") -> "TimePoint":
return prev_timepoint
return None
- def get_first_after(self, timepoint):
+ def get_first_after(self, timepoint: 'TimePoint') -> Optional['TimePoint']:
"""Return the next timepoint in the series after the given timepoint
which is not necessarily part of the series.
@@ -335,7 +336,7 @@ def get_first_after(self, timepoint):
return self._start_point
return None
- def __getitem__(self, index: int) -> "TimePoint":
+ def __getitem__(self, index: int) -> 'TimePoint':
if index < 0 or not isinstance(index, int):
raise IndexError("Unsupported index for TimeRecurrence")
for i, point in enumerate(self.__iter__()):
@@ -343,7 +344,7 @@ def __getitem__(self, index: int) -> "TimePoint":
return point
raise IndexError("Invalid index for TimeRecurrence")
- def _get_is_in_bounds(self, timepoint: "TimePoint") -> bool:
+ def _get_is_in_bounds(self, timepoint: 'TimePoint') -> bool:
"""Return whether the timepoint is within this recurrence series."""
if timepoint is None:
return False
@@ -384,7 +385,7 @@ def __hash__(self) -> int:
return hash((self._repetitions, self._start_point, self._end_point,
self._duration, self._min_point, self._max_point))
- def __eq__(self, other: "TimeRecurrence") -> bool:
+ def __eq__(self, other: object) -> bool:
if not isinstance(other, TimeRecurrence):
return NotImplemented
for attr in ["_repetitions", "_start_point", "_end_point", "_duration",
@@ -393,12 +394,9 @@ def __eq__(self, other: "TimeRecurrence") -> bool:
return False
return True
- def __add__(self, other: "Duration") -> "TimeRecurrence":
+ def __add__(self, other: 'Duration') -> 'TimeRecurrence':
if not isinstance(other, Duration):
- raise TypeError(
- "Invalid type for addition: '{0}' should be Duration."
- .format(type(other).__name__)
- )
+ return NotImplemented
if self._format_number == 1:
kwargs = {"start_point": self._start_point + other,
"end_point": self._second_point + other}
@@ -412,7 +410,9 @@ def __add__(self, other: "Duration") -> "TimeRecurrence":
repetitions=self._repetitions, **kwargs,
min_point=self._min_point, max_point=self._max_point)
- def __sub__(self, other: "Duration") -> "TimeRecurrence":
+ def __sub__(self, other: 'Duration') -> 'TimeRecurrence':
+ if not isinstance(other, Duration):
+ return NotImplemented
return self + -1 * other
def __str__(self):
@@ -552,19 +552,17 @@ def minutes(self): return self._minutes
@property
def seconds(self): return self._seconds
- def _copy(self):
+ def _copy(self) -> 'Duration':
"""Return an (unlinked) copy of this instance."""
new = self.__class__(_is_empty_instance=True)
for attr in self.__slots__:
setattr(new, attr, getattr(self, attr))
return new
- def is_exact(self):
+ def is_exact(self) -> bool:
"""Return True if the instance is defined in non-nominal/exact units
(weeks, days, hours, minutes or seconds) only."""
- if self._years or self._months:
- return False
- return True
+ return not (self._years or self._months)
def get_days_and_seconds(self):
"""Return a roughly-converted duration in days and seconds.
@@ -618,7 +616,7 @@ def get_is_in_weeks(self):
"""Return whether we are in week representation."""
return self._weeks is not None
- def to_days(self):
+ def to_days(self) -> 'Duration':
"""Return a new Duration in day representation rather than weeks."""
if self.get_is_in_weeks():
new = self._copy()
@@ -640,17 +638,27 @@ def to_weeks(self):
return Duration(weeks=weeks)
return self
- def __abs__(self):
- new = self._copy()
- for attribute in new.__slots__:
- attr_value = getattr(new, attribute)
- if attr_value is not None:
- setattr(new, attribute, abs(attr_value))
+ def __abs__(self) -> 'Duration':
+ new = self.__class__(_is_empty_instance=True)
+ for attr in self.__slots__:
+ value: Union[int, float, None] = getattr(self, attr)
+ setattr(new, attr, abs(value) if value else value)
return new
- def __add__(self, other):
- new = self._copy()
+ @overload
+ def __add__(self, other: 'Duration') -> 'Duration': ...
+
+ @overload
+ def __add__(self, other: 'TimePoint') -> 'TimePoint': ...
+
+ @overload
+ def __add__(self, other: 'TimeRecurrence') -> 'TimeRecurrence': ...
+
+ def __add__(
+ self, other: Union['Duration', 'TimePoint', 'TimeRecurrence']
+ ) -> Union['Duration', 'TimePoint', 'TimeRecurrence']:
if isinstance(other, Duration):
+ new = self._copy()
if new.get_is_in_weeks():
if other.get_is_in_weeks():
new._weeks += other._weeks
@@ -665,33 +673,29 @@ def __add__(self, other):
new._minutes += other._minutes
new._seconds += other._seconds
return new
- if isinstance(other, TimePoint) or isinstance(other, TimeRecurrence):
- return other + new
- raise TypeError(
- "Invalid type for addition: " +
- "'%s' should be Duration or TimePoint." %
- type(other).__name__
- )
+ if isinstance(other, (TimePoint, TimeRecurrence)):
+ return other + self
+ return NotImplemented
- def __sub__(self, other):
+ def __sub__(self, other: 'Duration') -> 'Duration':
+ if not isinstance(other, Duration):
+ return NotImplemented
return self + -1 * other
- def __mul__(self, other):
+ def __neg__(self) -> 'Duration':
+ return -1 * self
+
+ def __mul__(self, other: int) -> 'Duration':
# TODO: support float multiplication?
if not isinstance(other, int):
- raise TypeError(
- "Invalid type for multiplication: " +
- "'%s' should be integer." %
- type(other).__name__
- )
- new = self._copy()
- for attr in new.__slots__:
- value = getattr(new, attr)
- if value is not None:
- setattr(new, attr, value * other)
+ return NotImplemented
+ new = self.__class__(_is_empty_instance=True)
+ for attr in self.__slots__:
+ value: Union[int, float, None] = getattr(self, attr)
+ setattr(new, attr, value * other if value else value)
return new
- def __rmul__(self, other):
+ def __rmul__(self, other: int) -> 'Duration':
return self.__mul__(other)
def __floordiv__(self, other):
@@ -721,37 +725,36 @@ def __hash__(self) -> int:
return hash(
(self._years, self._months, self._get_non_nominal_seconds()))
- def __eq__(self, other: "Duration") -> bool:
- if isinstance(other, Duration):
- if self.is_exact():
- if other.is_exact():
- return (self._get_non_nominal_seconds() ==
- other._get_non_nominal_seconds())
- return False
- return (
- self._years == other._years and
- self._months == other._months and
- self._get_non_nominal_seconds() ==
- other._get_non_nominal_seconds()
- )
- return NotImplemented
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, Duration):
+ return NotImplemented
+ if self.is_exact():
+ if other.is_exact():
+ return (self._get_non_nominal_seconds() ==
+ other._get_non_nominal_seconds())
+ return False
+ return (
+ self._years == other._years and
+ self._months == other._months and
+ self._get_non_nominal_seconds() == other._get_non_nominal_seconds()
+ )
- def __lt__(self, other: "Duration") -> bool:
+ def __lt__(self, other: 'Duration') -> bool:
if isinstance(other, Duration):
return self.get_days_and_seconds() < other.get_days_and_seconds()
return NotImplemented
- def __le__(self, other: "Duration") -> bool:
+ def __le__(self, other: 'Duration') -> bool:
if isinstance(other, Duration):
return self.get_days_and_seconds() <= other.get_days_and_seconds()
return NotImplemented
- def __gt__(self, other: "Duration") -> bool:
+ def __gt__(self, other: 'Duration') -> bool:
if isinstance(other, Duration):
return self.get_days_and_seconds() > other.get_days_and_seconds()
return NotImplemented
- def __ge__(self, other: "Duration") -> bool:
+ def __ge__(self, other: 'Duration') -> bool:
if isinstance(other, Duration):
return self.get_days_and_seconds() >= other.get_days_and_seconds()
return NotImplemented
@@ -1300,12 +1303,12 @@ def time_zone_sign(self):
return "+"
@property
- def seconds_since_unix_epoch(self):
+ def seconds_since_unix_epoch(self) -> int:
reference_timepoint = TimePoint(
**CALENDAR.UNIX_EPOCH_DATE_TIME_REFERENCE_PROPERTIES)
days, seconds = (self - reference_timepoint).get_days_and_seconds()
# N.B. This needs altering if we implement leap seconds.
- return str(int(CALENDAR.SECONDS_IN_DAY * days + seconds))
+ return int(CALENDAR.SECONDS_IN_DAY * days + seconds)
def get(self, property_name):
"""Obsolete method for returning calculated value for property name."""
@@ -1357,7 +1360,7 @@ def get_week_date(self):
if self.get_is_week_date():
return self._year, self._week_of_year, self._day_of_week
- def get_time_zone_offset(self, other: "TimePoint") -> "Duration":
+ def get_time_zone_offset(self, other: 'TimePoint') -> 'Duration':
"""Get the difference in hours and minutes between time zones.
Args:
@@ -1368,7 +1371,7 @@ def get_time_zone_offset(self, other: "TimePoint") -> "Duration":
return Duration()
return other._time_zone - self._time_zone
- def to_time_zone(self, dest_time_zone: "TimeZone") -> "TimePoint":
+ def to_time_zone(self, dest_time_zone: 'TimeZone') -> 'TimePoint':
"""Return a copy of this TimePoint in the specified time zone.
Args:
@@ -1380,17 +1383,17 @@ def to_time_zone(self, dest_time_zone: "TimeZone") -> "TimePoint":
new._time_zone = dest_time_zone
return new
- def to_local_time_zone(self) -> "TimePoint":
+ def to_local_time_zone(self) -> 'TimePoint':
"""Return a copy of this TimePoint in the local time zone."""
local_hours, local_minutes = timezone.get_local_time_zone()
return self.to_time_zone(
TimeZone(hours=local_hours, minutes=local_minutes))
- def to_utc(self) -> "TimePoint":
+ def to_utc(self) -> 'TimePoint':
"""Return a copy of this TimePoint in the UTC time zone."""
return self.to_time_zone(TimeZone(hours=0, minutes=0))
- def to_calendar_date(self) -> "TimePoint":
+ def to_calendar_date(self) -> 'TimePoint':
"""Return a copy of this TimePoint reformatted in years, month-of-year
and day-of-month."""
if self.get_is_calendar_date():
@@ -1402,7 +1405,7 @@ def to_calendar_date(self) -> "TimePoint":
new._week_of_year, new._day_of_week = (None, None)
return new
- def to_hour_minute_second(self) -> "TimePoint":
+ def to_hour_minute_second(self) -> 'TimePoint':
"""Return a copy of this TimePoint with any time fractions expanded
into hours, minutes and seconds."""
new = self._copy()
@@ -1410,7 +1413,7 @@ def to_hour_minute_second(self) -> "TimePoint":
self.get_hour_minute_second())
return new
- def to_week_date(self) -> "TimePoint":
+ def to_week_date(self) -> 'TimePoint':
"""Return a copy of this TimePoint reformatted in years, week-of-year
and day-of-week."""
if self.get_is_week_date():
@@ -1421,7 +1424,7 @@ def to_week_date(self) -> "TimePoint":
new._month_of_year, new._day_of_month = (None, None)
return new
- def to_ordinal_date(self) -> "TimePoint":
+ def to_ordinal_date(self) -> 'TimePoint':
"""Return a copy of this TimePoint reformatted in years and
day-of-the-year."""
new = self._copy()
@@ -1430,20 +1433,15 @@ def to_ordinal_date(self) -> "TimePoint":
new._week_of_year, new._day_of_week = (None, None)
return new
- def get_largest_truncated_property_name(self):
+ def get_largest_truncated_property_name(self) -> Optional[str]:
"""Return the largest unit in a truncated representation."""
- if not self._truncated:
+ truncated_props = self.get_truncated_properties()
+ if not truncated_props:
return None
- prop_dict = self.get_truncated_properties()
- for attr in ["year_of_century", "year_of_decade", "month_of_year",
- "week_of_year", "day_of_year", "day_of_month",
- "day_of_week", "hour_of_day", "minute_of_hour",
- "second_of_minute"]:
- if attr in prop_dict:
- return attr
- return None
+ # Relies on dict being ordered in Python 3.6+:
+ return next(iter(truncated_props))
- def get_smallest_missing_property_name(self):
+ def get_smallest_missing_property_name(self) -> Optional[str]:
"""Return the smallest unit missing from a truncated representation."""
if not self._truncated:
return None
@@ -1463,100 +1461,192 @@ def get_smallest_missing_property_name(self):
return attr_value
return None
- def get_truncated_properties(self):
- """Return a map of properties if this is a truncated representation."""
+ def get_truncated_properties(self) -> Optional[Dict[str, float]]:
+ """Return a map of properties if this is a truncated representation.
+
+ Ordered from largest unit to smallest.
+ """
if not self._truncated:
return None
props = {}
if self._truncated_property == "year_of_decade":
- props.update({"year_of_decade": self._year % 10})
- if self._truncated_property == "year_of_century":
- props.update({"year_of_century": self._year % 100})
+ props['year_of_decade'] = self._year % 10
+ elif self._truncated_property == "year_of_century":
+ props['year_of_century'] = self._year % 100
for attr in ["month_of_year", "week_of_year", "day_of_year",
"day_of_month", "day_of_week", "hour_of_day",
"minute_of_hour", "second_of_minute"]:
- value = getattr(self, "_{0}".format(attr))
+ value = getattr(self, f"_{attr}")
if value is not None:
- props.update({attr: value})
+ props[attr] = value
return props
- def add_truncated(self, year_of_century=None, year_of_decade=None,
- month_of_year=None, week_of_year=None, day_of_year=None,
- day_of_month=None, day_of_week=None, hour_of_day=None,
- minute_of_hour=None, second_of_minute=None):
- """Returns a copy of this TimePoint with truncated time properties
+ def add_truncated(self, other: 'TimePoint') -> 'TimePoint':
+ """Returns a copy of this TimePoint with the other, truncated TimePoint
added to it."""
new = self._copy()
- if hour_of_day is not None and minute_of_hour is None:
- minute_of_hour = 0
- if ((hour_of_day is not None or minute_of_hour is not None) and
- second_of_minute is None):
- second_of_minute = 0
+ props = other.get_truncated_properties()
+ assert props is not None # nosec B101 (this method only for truncated)
+ largest_unit = next(iter(props))
+
+ # Time units are assumed to be 0 if not specified and the largest
+ # specified unit is higher up
+ for unit in ('second_of_minute', 'minute_of_hour', 'hour_of_day'):
+ if largest_unit == unit:
+ break
+ if unit not in props:
+ props[unit] = 0
+
+ year_of_century = cast('Optional[int]', props.get('year_of_century'))
+ year_of_decade = cast('Optional[int]', props.get('year_of_decade'))
+ month_of_year = cast('Optional[int]', props.get('month_of_year'))
+ week_of_year = cast('Optional[int]', props.get('week_of_year'))
+ day_of_year = cast('Optional[int]', props.get('day_of_year'))
+ day_of_month = cast('Optional[int]', props.get('day_of_month'))
+ day_of_week = cast('Optional[int]', props.get('day_of_week'))
+ hour_of_day = props.get('hour_of_day')
+ minute_of_hour = props.get('minute_of_hour')
+ second_of_minute = props.get('second_of_minute')
+
if second_of_minute is not None or minute_of_hour is not None:
new = new.to_hour_minute_second()
if second_of_minute is not None:
- while new._second_of_minute != second_of_minute:
- new._second_of_minute += 1.0
- new._tick_over()
+ new._second_of_minute += (
+ (second_of_minute - new._second_of_minute)
+ % CALENDAR.SECONDS_IN_MINUTE
+ )
+ new._tick_over()
if minute_of_hour is not None:
- while new._minute_of_hour != minute_of_hour:
- new._minute_of_hour += 1.0
- new._tick_over()
+ new._minute_of_hour += (
+ (minute_of_hour - new._minute_of_hour)
+ % CALENDAR.MINUTES_IN_HOUR
+ )
+ new._tick_over()
if hour_of_day is not None:
- while new._hour_of_day != hour_of_day:
- new._hour_of_day += 1.0
- new._tick_over()
+ new._hour_of_day += (
+ (hour_of_day - new._hour_of_day) % CALENDAR.HOURS_IN_DAY
+ )
+ new._tick_over()
+
if day_of_week is not None:
new = new.to_week_date()
while new._day_of_week != day_of_week:
new._day_of_week += 1
new._tick_over()
- if day_of_month is not None:
- new = new.to_calendar_date()
- while new._day_of_month != day_of_month:
- new._day_of_month += 1
- new._tick_over()
- if day_of_year is not None:
- new = new.to_ordinal_date()
- while new._day_of_year != day_of_year:
- new._day_of_year += 1
- new._tick_over()
if week_of_year is not None:
new = new.to_week_date()
while new._week_of_year != week_of_year:
new._week_of_year += 1
new._tick_over()
- if month_of_year is not None:
+
+ if day_of_month or month_of_year:
new = new.to_calendar_date()
- while new._month_of_year != month_of_year:
- new._month_of_year += 1
+ # Set next date that satisfies day & month provided
+ new._next_month_and_day(month_of_year, day_of_month)
+
+ if day_of_year is not None:
+ new = new.to_ordinal_date()
+ while new._day_of_year != day_of_year:
+ new._day_of_year += 1
new._tick_over()
- if year_of_decade is not None:
- new = new.to_calendar_date()
- new_year_of_decade = new._year % 10
- while new_year_of_decade != year_of_decade:
- new._year += 1
- new_year_of_decade = new._year % 10
- if year_of_century is not None:
+
+ if year_of_decade is not None or year_of_century is not None:
+ # BUG: converting to calendar date can lead to bad results for
+ # truncated week dates (though having a truncated year of decade
+ # or century is not documented for week dates)
new = new.to_calendar_date()
- new_year_of_century = new._year % 100
- while new_year_of_century != year_of_century:
- new._year += 1
- new_year_of_century = new._year % 100
+ if day_of_month is None:
+ new._day_of_month = 1
+ if month_of_year is None:
+ new._month_of_year = 1
+
+ if year_of_decade is not None:
+ prop, factor = year_of_decade, 10
+ else:
+ prop, factor = year_of_century, 100
+
+ # Skip to next matching year:
+ new._year += (prop - new._year % factor) % factor
+
+ if new < self:
+ # We are still on the same year but must have set the day or
+ # month to 1, so skip to the next matching year:
+ new._year += factor
+
+ if new._day_of_month > get_days_in_month(
+ new._month_of_year, new._year
+ ):
+ # Skip to next matching leap year:
+ while True:
+ new._year += factor
+ if get_is_leap_year(new._year):
+ break
+
return new
- def __add__(self, other) -> "TimePoint":
+ def _next_month_and_day(
+ self, month: Optional[int], day: Optional[int]
+ ) -> None:
+ """Get the next TimePoint after this one that has the
+ same month and/or day as specified.
+
+ WARNING: mutates self instance.
+
+ If no day is specified, it will be taken to be the 1st of the month.
+
+ If the day and month match this TimePoint, it will be unaltered.
+ """
+ if day is None:
+ day = 1
+ years_to_check: List[int] = [self._year, self._year + 1]
+ for i, year in enumerate(years_to_check):
+ self._year = year
+ if month:
+ if day <= get_days_in_month(month, year) and (
+ month > self._month_of_year or (
+ month == self._month_of_year and
+ day >= self._day_of_month
+ )
+ ):
+ self._month_of_year = month
+ self._day_of_month = day
+ return
+ else:
+ for month_ in range(
+ self._month_of_year, CALENDAR.MONTHS_IN_YEAR + 1
+ ):
+ if self._day_of_month <= day <= get_days_in_month(
+ month_, year
+ ):
+ self._month_of_year = month_
+ self._day_of_month = day
+ return
+ self._day_of_month = 1
+ self._month_of_year = 1
+ self._day_of_month = 1
+ if i == 1:
+ # Didn't find it - check next leap year if applicable
+ next_leap_year = find_next_leap_year(self._year)
+ if next_leap_year not in {None, *years_to_check}:
+ years_to_check.append(cast('int', next_leap_year))
+ raise ValueError(
+ f"Invalid month of year {month} or day of month {day}"
+ )
+
+ def __add__(self, other: Union['Duration', 'TimePoint']) -> 'TimePoint':
if isinstance(other, TimePoint):
if self._truncated and not other._truncated:
new = other.to_time_zone(self._time_zone)
- new = new.add_truncated(**self.get_truncated_properties())
+ new = new.add_truncated(self)
return new.to_time_zone(other._time_zone)
if other._truncated and not self._truncated:
return other + self
- if not isinstance(other, Duration):
raise ValueError(
- "Invalid addition: can only add Duration or "
- "truncated TimePoint to TimePoint.")
+ "Invalid addition: can only add two TimePoints if one is a "
+ "truncated TimePoint."
+ )
+ if not isinstance(other, Duration):
+ return NotImplemented
duration = other
if duration.get_is_in_weeks():
duration = duration.to_days()
@@ -1618,7 +1708,7 @@ def __add__(self, other) -> "TimePoint":
new._week_of_year = max_weeks_in_year
return new
- def _copy(self) -> "TimePoint":
+ def _copy(self) -> 'TimePoint':
"""Returns an unlinked copy of this instance."""
new_timepoint = TimePoint(is_empty_instance=True)
for attr in self.__slots__:
@@ -1646,7 +1736,7 @@ def __hash__(self) -> int:
return hash((*point.get_calendar_date(),
*point.get_hour_minute_second()))
- def _cmp(self, other: "TimePoint", op: str) -> bool:
+ def _cmp(self, other: object, op: str) -> bool:
"""Compare self with other, using the chosen operator.
Args:
@@ -1660,7 +1750,7 @@ def _cmp(self, other: "TimePoint", op: str) -> bool:
"Cannot compare truncated to non-truncated "
"TimePoint: {0}, {1}".format(self, other))
if self.get_props() == other.get_props():
- return True if op in ["eq", "le", "ge"] else False
+ return op in {"eq", "le", "ge"}
if self._truncated:
# TODO: Convert truncated TimePoints to UTC when not buggy
for attribute in self.__slots__:
@@ -1680,23 +1770,36 @@ def _cmp(self, other: "TimePoint", op: str) -> bool:
other_datetime = [*other_date, other.get_second_of_day()]
return _operator_map[op](my_datetime, other_datetime)
- def __eq__(self, other: "TimePoint") -> bool:
+ def __eq__(self, other: object) -> bool:
return self._cmp(other, "eq")
- def __lt__(self, other: "TimePoint") -> bool:
+ def __lt__(self, other: 'TimePoint') -> bool:
return self._cmp(other, "lt")
- def __le__(self, other: "TimePoint") -> bool:
+ def __le__(self, other: 'TimePoint') -> bool:
return self._cmp(other, "le")
- def __gt__(self, other: "TimePoint") -> bool:
+ def __gt__(self, other: 'TimePoint') -> bool:
return self._cmp(other, "gt")
- def __ge__(self, other: "TimePoint") -> bool:
+ def __ge__(self, other: 'TimePoint') -> bool:
return self._cmp(other, "ge")
- def __sub__(self, other):
+ @overload
+ def __sub__(self, other: 'Duration') -> 'TimePoint': ...
+
+ @overload
+ def __sub__(self, other: 'TimePoint') -> 'Duration': ...
+
+ def __sub__(
+ self, other: Union['Duration', 'TimePoint']
+ ) -> Union['TimePoint', 'Duration']:
if isinstance(other, TimePoint):
+ if self._truncated or other._truncated:
+ raise ValueError(
+ "Invalid subtraction: can only subtract non-truncated "
+ "TimePoints from one another."
+ )
if other > self:
return -1 * (other - self)
other = other.to_time_zone(self._time_zone)
@@ -1726,15 +1829,10 @@ def __sub__(self, other):
days=diff_day, hours=diff_hour, minutes=diff_minute,
seconds=diff_second)
if not isinstance(other, Duration):
- raise TypeError(
- "Invalid subtraction type " +
- "'%s' - should be Duration." %
- type(other).__name__
- )
- duration = other
- return self.__add__(duration * -1)
+ return NotImplemented
+ return self + -1 * other
- def add_months(self, num_months):
+ def add_months(self, num_months: int) -> 'TimePoint':
"""Return a copy of this TimePoint with an amount of months added to
it."""
if num_months == 0:
@@ -1879,8 +1977,9 @@ def _tick_over_day_of_month(self):
day = None
while num_days != self._day_of_month:
start_year -= 1
- for month, day in iter_months_days(
- start_year, in_reverse=True):
+ for month, day in iter_months_days( # noqa: B007
+ start_year, in_reverse=True
+ ):
num_days -= 1
if num_days == self._day_of_month:
break
@@ -1894,17 +1993,18 @@ def _tick_over_day_of_month(self):
else:
max_day_in_month = CALENDAR.DAYS_IN_MONTHS[month_index]
if self._day_of_month > max_day_in_month:
- num_days = 0
+ num_days = 0 # noqa: SIM113
for month, day in iter_months_days(
- self._year,
- month_of_year=self._month_of_year,
- day_of_month=1):
+ self._year,
+ month_of_year=self._month_of_year,
+ day_of_month=1
+ ):
num_days += 1
if num_days == self._day_of_month:
self._month_of_year = month
self._day_of_month = day
break
- else:
+ else: # no break
start_year = self._year
while num_days != self._day_of_month:
start_year += 1
@@ -2125,6 +2225,18 @@ def get_is_leap_year(year):
return year_is_leap
+def find_next_leap_year(year: int) -> Optional[int]:
+ """Find the next leap year after or including this year.
+
+ Returns None if calendar does not have leap years."""
+ if CALENDAR.MODES[CALENDAR.mode][1] is None:
+ return None
+ while True:
+ if get_is_leap_year(year):
+ return year
+ year += 1
+
+
def get_days_in_year_range(start_year, end_year):
"""Return the number of days within this year range (inclusive)."""
return _get_days_in_year_range(start_year, end_year, CALENDAR.mode)
@@ -2508,16 +2620,21 @@ def get_timepoint_properties_from_seconds_since_unix_epoch(num_seconds):
return properties
-def iter_months_days(year, month_of_year=None, day_of_month=None,
- in_reverse=False):
+def iter_months_days(
+ year: int,
+ month_of_year: Optional[int] = None,
+ day_of_month: Optional[int] = None,
+ in_reverse: bool = False
+) -> List[Tuple[int, int]]:
"""Iterate over each day in each month of year.
- year is an integer specifying the year to use.
- month_of_year is an optional integer, specifying a start month.
- day_of_month is an optional integer, specifying a start day.
- in_reverse is an optional boolean that reverses the iteration if
- True (default False).
+ Args:
+ year - year to use.
+ month_of_year - start month.
+ day_of_month - start day.
+ in_reverse - reverses the iteration if True.
+ Returns list of (month_of_year, day_of_month) tuples.
"""
is_leap_year = get_is_leap_year(year)
return _iter_months_days(
@@ -2525,8 +2642,13 @@ def iter_months_days(year, month_of_year=None, day_of_month=None,
@lru_cache(maxsize=100000)
-def _iter_months_days(is_leap_year, month_of_year, day_of_month, _,
- in_reverse=False):
+def _iter_months_days(
+ is_leap_year: bool,
+ month_of_year: int,
+ day_of_month: int,
+ _cal_mode,
+ in_reverse: bool = False
+) -> List[Tuple[int, int]]:
if day_of_month is not None and month_of_year is None:
raise ValueError("Need to specify start month as well as day.")
source = CALENDAR.INDEXED_DAYS_IN_MONTHS
diff --git a/metomi/isodatetime/py.typed b/metomi/isodatetime/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py
index 7adab88..d406fe2 100644
--- a/metomi/isodatetime/tests/test_01.py
+++ b/metomi/isodatetime/tests/test_01.py
@@ -18,11 +18,39 @@
# ----------------------------------------------------------------------------
"""This tests the ISO 8601 data model functionality."""
+from typing import Optional, Union
import pytest
import unittest
from metomi.isodatetime import data
+from metomi.isodatetime.data import Calendar, Duration, TimePoint
from metomi.isodatetime.exceptions import BadInputError
+from metomi.isodatetime.parsers import TimePointParser
+
+
+@pytest.fixture
+def patch_calendar_mode(monkeypatch: pytest.MonkeyPatch):
+ """Fixture for setting calendar mode on singleton CALENDAR instance."""
+ def _patch_calendar_mode(mode: str) -> None:
+ calendar = Calendar()
+ calendar.set_mode(mode)
+ monkeypatch.setattr(data, 'CALENDAR', calendar)
+
+ return _patch_calendar_mode
+
+
+def add_param(obj, other, expected):
+ """pytest.param with nicely formatted IDs for addition tests."""
+ return pytest.param(
+ obj, other, expected, id=f"{obj} + {other} = {expected}"
+ )
+
+
+def subtract_param(obj, other, expected):
+ """pytest.param with nicely formatted IDs for subtraction tests."""
+ return pytest.param(
+ obj, other, expected, id=f"{obj} - {other} = {expected}"
+ )
def get_timeduration_tests():
@@ -579,104 +607,378 @@ def test_duration_comparison(self):
with self.assertRaises(TypeError):
dur < var
- def test_timeduration_add_week(self):
- """Test the Duration not in weeks add Duration in weeks."""
- self.assertEqual(
- str(data.Duration(days=7) + data.Duration(weeks=1)),
- "P14D")
-
- def test_duration_floordiv(self):
- """Test the existing dunder floordir, which will be removed when we
- move to Python 3"""
- duration = data.Duration(years=4, months=4, days=4, hours=4,
- minutes=4, seconds=4)
- expected = data.Duration(years=2, months=2, days=2, hours=2,
- minutes=2, seconds=2)
- duration //= 2
- self.assertEqual(duration, expected)
-
- def test_duration_in_weeks_floordiv(self):
- """Test the existing dunder floordir, which will be removed when we
- move to Python 3"""
- duration = data.Duration(weeks=4)
- duration //= 2
- self.assertEqual(2, duration.weeks)
-
- def test_duration_subtract(self):
- """Test subtracting a duration from a timepoint."""
- for test in get_duration_subtract_tests():
- start_point = data.TimePoint(**test["start"])
- test_duration = data.Duration(**test["duration"])
- end_point = data.TimePoint(**test["result"])
- test_subtract = (start_point - test_duration).to_calendar_date()
- self.assertEqual(test_subtract, end_point,
- "%s - %s" % (start_point, test_duration))
-
- def test_timepoint_comparison(self):
- """Test the TimePoint rich comparison methods and hashing."""
- run_comparison_tests(data.TimePoint, get_timepoint_comparison_tests())
- point = data.TimePoint(year=2000)
- for var in [7, 'foo', (1, 2), data.Duration(days=1)]:
- self.assertFalse(point == var)
- with self.assertRaises(TypeError):
- point < var
- # Cannot use "<", ">=" etc truncated TimePoints of different modes:
- day_month_point = data.TimePoint(month_of_year=2, day_of_month=5,
- truncated=True)
- ordinal_point = data.TimePoint(day_of_year=36, truncated=True)
- with self.assertRaises(TypeError): # TODO: should be ValueError?
- day_month_point < ordinal_point
-
- def test_timepoint_plus_float_time_duration_day_of_month_type(self):
- """Test (TimePoint + Duration).day_of_month is an int."""
- time_point = data.TimePoint(year=2000) + data.Duration(seconds=1.0)
- self.assertEqual(type(time_point.day_of_month), int)
-
- def test_timepoint_subtract(self):
- """Test subtracting one time point from another."""
- for test_props1, test_props2, ctrl_string in (
- get_timepoint_subtract_tests()):
- point1 = data.TimePoint(**test_props1)
- point2 = data.TimePoint(**test_props2)
- test_string = str(point1 - point2)
- self.assertEqual(test_string, ctrl_string,
- "%s - %s" % (point1, point2))
-
- def test_timepoint_add_duration(self):
- """Test adding a duration to a timepoint"""
- seconds_added = 5
- timepoint = data.TimePoint(year=1900, month_of_year=1, day_of_month=1,
- hour_of_day=1, minute_of_hour=1)
- duration = data.Duration(seconds=seconds_added)
- t = timepoint + duration
- self.assertEqual(seconds_added, t.second_of_minute)
-
- def test_timepoint_add_duration_without_minute(self):
- """Test adding a duration to a timepoint"""
- seconds_added = 5
- timepoint = data.TimePoint(year=1900, month_of_year=1, day_of_month=1,
- hour_of_day=1)
- duration = data.Duration(seconds=seconds_added)
- t = timepoint + duration
- self.assertEqual(seconds_added, t.second_of_minute)
-
- def test_timepoint_bounds(self):
- """Test out of bounds TimePoints"""
- tests = get_timepoint_bounds_tests()
- for kwargs in tests["in_bounds"]:
+
+@pytest.mark.parametrize('duration, other, expected', [
+ add_param(
+ Duration(hours=1, minutes=6), Duration(hours=3),
+ Duration(hours=4, minutes=6)
+ ),
+ add_param(
+ Duration(days=7), Duration(weeks=1), Duration(days=14)
+ ),
+ add_param(
+ Duration(years=2), Duration(seconds=43),
+ Duration(years=2, seconds=43)
+ ),
+])
+def test_duration_add(
+ duration: Duration, other: Duration, expected: Duration
+):
+ """Test adding Durations."""
+ for left, right in [(duration, other), (other, duration)]:
+ assert left + right == expected
+ assert str(left + right) == str(expected)
+
+
+@pytest.mark.parametrize('duration, other, expected', [
+ subtract_param(
+ Duration(hours=15), Duration(hours=3), Duration(hours=12)
+ ),
+ subtract_param(
+ Duration(years=2, months=3, days=4), Duration(years=1, days=2),
+ Duration(years=1, months=3, days=2)
+ ),
+ subtract_param(
+ Duration(hours=1, minutes=6), Duration(hours=3),
+ Duration(hours=-1, minutes=-54)
+ ),
+])
+def test_duration_subtract(
+ duration: Duration, other: Duration, expected: Duration
+):
+ """Test subtracting Durations."""
+ assert duration - other == expected
+ # assert str(duration - other) == str(expected) # BUG with string
+ # representation of LHS for negative results?
+
+
+@pytest.mark.parametrize('duration, expected', [
+ (
+ Duration(years=4, months=4, days=4, hours=4, minutes=4, seconds=4),
+ Duration(years=2, months=2, days=2, hours=2, minutes=2, seconds=2)
+ ),
+ (
+ Duration(weeks=4), Duration(weeks=2)
+ ),
+])
+def test_duration_floordiv(duration: Duration, expected: Duration):
+ duration //= 2
+ assert duration == expected
+
+
+@pytest.mark.parametrize('duration, expected', [
+ (Duration(years=1), False),
+ (Duration(months=1), False),
+ (Duration(weeks=1), True),
+ (Duration(days=1), True),
+ (Duration(weeks=1, days=1), True),
+ (Duration(months=1, days=1), False),
+])
+def test_duration_is_exact(duration: Duration, expected: bool):
+ """Test Duration.is_exact()."""
+ assert duration.is_exact() is expected
+
+
+def test_duration_neg():
+ """Test negating a Duration."""
+ duration = Duration(years=1, months=2, days=3, hours=4, seconds=6)
+ assert -duration == Duration(
+ years=-1, months=-2, days=-3, hours=-4, seconds=-6
+ )
+ assert str(-duration) == "-P1Y2M3DT4H6S"
+
+
+def test_timepoint_comparison():
+ """Test the TimePoint rich comparison methods and hashing."""
+ run_comparison_tests(data.TimePoint, get_timepoint_comparison_tests())
+ point = data.TimePoint(year=2000)
+ for var in [7, 'foo', (1, 2), data.Duration(days=1)]:
+ assert not (point == var)
+ assert point != var
+ with pytest.raises(TypeError):
+ point < var
+ # Cannot use "<", ">=" etc truncated TimePoints of different modes:
+ day_month_point = data.TimePoint(
+ month_of_year=2, day_of_month=5, truncated=True
+ )
+ ordinal_point = data.TimePoint(day_of_year=36, truncated=True)
+ with pytest.raises(TypeError): # TODO: should be ValueError?
+ day_month_point < ordinal_point
+
+
+def test_timepoint_plus_float_time_duration_day_of_month_type():
+ """Test (TimePoint + Duration).day_of_month is an int."""
+ time_point = data.TimePoint(year=2000) + data.Duration(seconds=1.0)
+ assert isinstance(time_point.day_of_month, int)
+
+
+@pytest.mark.parametrize(
+ 'test_props1, test_props2, ctrl_string', get_timepoint_subtract_tests()
+)
+def test_timepoint_subtract(test_props1, test_props2, ctrl_string):
+ """Test subtracting one time point from another."""
+ point1 = TimePoint(**test_props1)
+ point2 = TimePoint(**test_props2)
+ test_string = str(point1 - point2)
+ assert test_string == ctrl_string
+
+
+def test_timepoint_subtract_truncated():
+ """Test an error is raised if subtracting a truncated TimePoint from
+ a non-truncated one and vice versa."""
+ msg = r"Invalid subtraction"
+ with pytest.raises(ValueError, match=msg):
+ TimePoint(year=2000) - TimePoint(day_of_month=2, truncated=True)
+ with pytest.raises(ValueError, match=msg):
+ TimePoint(day_of_month=2, truncated=True) - TimePoint(year=2000)
+
+
+@pytest.mark.parametrize('test', get_duration_subtract_tests())
+def test_timepoint_duration_subtract(test):
+ """Test subtracting a duration from a timepoint."""
+ start_point = TimePoint(**test["start"])
+ test_duration = Duration(**test["duration"])
+ end_point = TimePoint(**test["result"])
+ test_subtract = (start_point - test_duration).to_calendar_date()
+ assert test_subtract == end_point
+
+
+@pytest.mark.parametrize(
+ 'timepoint, other, expected',
+ [
+ add_param(
+ data.TimePoint(
+ year=1900, month_of_year=1, day_of_month=1, hour_of_day=1,
+ minute_of_hour=1
+ ),
+ data.Duration(seconds=5),
+ data.TimePoint(
+ year=1900, month_of_year=1, day_of_month=1, hour_of_day=1,
+ minute_of_hour=1, second_of_minute=5
+ ),
+ ),
+ add_param(
+ data.TimePoint(
+ year=1900, month_of_year=1, day_of_month=1, hour_of_day=1
+ ),
+ data.Duration(seconds=5),
+ data.TimePoint(
+ year=1900, month_of_year=1, day_of_month=1, hour_of_day=1,
+ second_of_minute=5
+ ),
+ ),
+ add_param(
+ data.TimePoint(year=1990, day_of_month=14, hour_of_day=1),
+ data.Duration(years=2, months=11, days=5, hours=26, minutes=32),
+ data.TimePoint(
+ year=1992, month_of_year=12, day_of_month=20,
+ hour_of_day=3, minute_of_hour=32
+ )
+ ),
+ add_param(
+ data.TimePoint(year=1994, day_of_month=2, hour_of_day=5),
+ data.Duration(months=0),
+ data.TimePoint(year=1994, day_of_month=2, hour_of_day=5),
+ ),
+ add_param(
+ data.TimePoint(year=2000),
+ data.TimePoint(month_of_year=3, day_of_month=30, truncated=True),
+ data.TimePoint(year=2000, month_of_year=3, day_of_month=30),
+ ),
+ add_param(
+ data.TimePoint(year=2000),
+ data.TimePoint(month_of_year=2, day_of_month=15, truncated=True),
+ data.TimePoint(year=2000, month_of_year=2, day_of_month=15),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=15),
+ data.TimePoint(day_of_month=15, truncated=True),
+ data.TimePoint(year=2000, day_of_month=15),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=15),
+ data.TimePoint(month_of_year=1, day_of_month=15, truncated=True),
+ data.TimePoint(year=2000, day_of_month=15),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=15),
+ data.TimePoint(month_of_year=1, day_of_month=14, truncated=True),
+ data.TimePoint(year=2001, day_of_month=14),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=15),
+ data.TimePoint(day_of_month=14, truncated=True),
+ data.TimePoint(year=2000, month_of_year=2, day_of_month=14),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=4, second_of_minute=1),
+ data.TimePoint(day_of_month=4, truncated=True),
+ data.TimePoint(year=2000, month_of_year=2, day_of_month=4),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=4, second_of_minute=1),
+ data.TimePoint(day_of_month=4, second_of_minute=1, truncated=True),
+ data.TimePoint(year=2000, day_of_month=4, second_of_minute=1),
+ ),
+ add_param(
+ data.TimePoint(year=2000, day_of_month=31),
+ data.TimePoint(day_of_month=2, hour_of_day=7, truncated=True),
+ data.TimePoint(
+ year=2000, month_of_year=2, day_of_month=2, hour_of_day=7,
+ ),
+ ),
+ add_param(
+ data.TimePoint(year=2001, month_of_year=2),
+ data.TimePoint(day_of_month=31, truncated=True),
+ data.TimePoint(year=2001, month_of_year=3, day_of_month=31),
+ ),
+ add_param(
+ data.TimePoint(year=2001),
+ data.TimePoint(month_of_year=2, day_of_month=29, truncated=True),
+ data.TimePoint(year=2004, month_of_year=2, day_of_month=29),
+ ),
+ add_param(
+ data.TimePoint(year=2001, day_of_month=6),
+ data.TimePoint(month_of_year=3, truncated=True),
+ data.TimePoint(year=2001, month_of_year=3, day_of_month=1),
+ ),
+ add_param(
+ data.TimePoint(year=2002, month_of_year=4, day_of_month=8),
+ data.TimePoint(month_of_year=1, truncated=True),
+ data.TimePoint(year=2003, month_of_year=1, day_of_month=1),
+ ),
+ add_param(
+ data.TimePoint(year=2002, month_of_year=4, day_of_month=8),
+ data.TimePoint(day_of_month=1, truncated=True),
+ data.TimePoint(year=2002, month_of_year=5, day_of_month=1),
+ ),
+ add_param(
+ data.TimePoint(year=2004),
+ data.TimePoint(hour_of_day=3, truncated=True),
+ data.TimePoint(year=2004, hour_of_day=3),
+ ),
+ add_param(
+ data.TimePoint(year=2004, hour_of_day=3, second_of_minute=1),
+ data.TimePoint(hour_of_day=3, truncated=True),
+ data.TimePoint(year=2004, day_of_month=2, hour_of_day=3),
+ ),
+ add_param(
+ data.TimePoint(year=2010, hour_of_day=19, minute_of_hour=41),
+ data.TimePoint(minute_of_hour=15, truncated=True),
+ data.TimePoint(year=2010, hour_of_day=20, minute_of_hour=15),
+ ),
+ add_param(
+ data.TimePoint(year=2010, hour_of_day=19, minute_of_hour=41),
+ data.TimePoint(month_of_year=3, minute_of_hour=15, truncated=True),
+ data.TimePoint(year=2010, month_of_year=3, minute_of_hour=15),
+ ),
+ add_param(
+ data.TimePoint(year=2077, day_of_month=21),
+ data.TimePoint(
+ year=7, truncated=True, truncated_property="year_of_decade"
+ ),
+ data.TimePoint(year=2087),
+ ),
+ add_param(
+ data.TimePoint(year=3000),
+ data.TimePoint(
+ year=0, month_of_year=2, day_of_month=29,
+ truncated=True, truncated_property="year_of_decade",
+ ),
+ data.TimePoint(year=3020, month_of_year=2, day_of_month=29),
+ ),
+ add_param(
+ data.TimePoint(year=3000),
+ data.TimePoint(
+ year=0, month_of_year=2, day_of_month=29,
+ truncated=True, truncated_property="year_of_century",
+ ),
+ data.TimePoint(year=3200, month_of_year=2, day_of_month=29),
+ ),
+ add_param(
+ data.TimePoint(year=3012, month_of_year=10, hour_of_day=9),
+ data.TimePoint(day_of_year=63, truncated=True),
+ data.TimePoint(year=3013, day_of_year=63),
+ ),
+ ],
+)
+def test_timepoint_add(
+ timepoint: data.TimePoint,
+ other: Union[data.Duration, data.TimePoint],
+ expected: data.TimePoint
+):
+ """Test adding to a timepoint"""
+ forward = timepoint + other
+ assert forward == expected
+ backward = other + timepoint
+ assert backward == expected
+
+
+@pytest.mark.parametrize(
+ 'timepoint, other, expected',
+ [
+ add_param(
+ '1990-04-15T00Z',
+ '-11-02',
+ data.TimePoint(year=2011, month_of_year=2, day_of_month=1)
+ ),
+ add_param(
+ '2008-01-01T02Z',
+ '-08',
+ data.TimePoint(year=2108),
+ ),
+ add_param(
+ '2008-01-01T02Z',
+ '-08T02Z',
+ data.TimePoint(year=2008, hour_of_day=2),
+ ),
+ add_param(
+ '2009-01-04T00Z',
+ '-09',
+ data.TimePoint(year=2109),
+ ),
+ add_param(
+ '2014-04-12T00Z',
+ '-14-04',
+ data.TimePoint(year=2114, month_of_year=4, day_of_month=1)
+ ),
+ add_param(
+ '2014-04-01T00Z',
+ '-14-04',
+ data.TimePoint(year=2014, month_of_year=4, day_of_month=1)
+ ),
+ ]
+)
+def test_timepoint_add__extra(
+ timepoint: str, other: str, expected: data.TimePoint
+):
+ parser = TimePointParser(allow_truncated=True, assumed_time_zone=(0, 0))
+ parsed_timepoint = parser.parse(timepoint)
+ parsed_other = parser.parse(other)
+ forward = parsed_timepoint + parsed_other
+ assert forward == expected
+ backward = parsed_other + parsed_timepoint
+ assert backward == expected
+
+
+def test_timepoint_bounds():
+ """Test out of bounds TimePoints"""
+ tests = get_timepoint_bounds_tests()
+ for kwargs in tests["in_bounds"]:
+ data.TimePoint(**kwargs)
+ for kwargs in tests["out_of_bounds"]:
+ with pytest.raises(BadInputError) as exc_info:
data.TimePoint(**kwargs)
- for kwargs in tests["out_of_bounds"]:
- with self.assertRaises(BadInputError) as cm:
- data.TimePoint(**kwargs)
- assert "out of bounds" in str(cm.exception)
+ assert "out of bounds" in str(exc_info.value)
+
- def test_timepoint_conflicting_inputs(self):
- """Test TimePoints initialized with incompatible inputs"""
- tests = get_timepoint_conflicting_input_tests()
- for kwargs in tests:
- with self.assertRaises(BadInputError) as cm:
- data.TimePoint(**kwargs)
- assert "Conflicting input" in str(cm.exception)
+def test_timepoint_conflicting_inputs():
+ """Test TimePoints initialized with incompatible inputs"""
+ tests = get_timepoint_conflicting_input_tests()
+ for kwargs in tests:
+ with pytest.raises(BadInputError) as exc_info:
+ data.TimePoint(**kwargs)
+ assert "Conflicting input" in str(exc_info.value)
def test_timepoint_without_year():
@@ -689,3 +991,23 @@ def test_timepoint_without_year():
# If truncated, it's fine:
data.TimePoint(truncated=True, month_of_year=2)
# TODO: what about just TimePoint(truncated=True) ?
+
+
+@pytest.mark.parametrize(
+ 'calendar_mode, year, expected',
+ [
+ (Calendar.MODE_GREGORIAN, 2004, 2004),
+ (Calendar.MODE_GREGORIAN, 2001, 2004),
+ (Calendar.MODE_GREGORIAN, 2000, 2000),
+ (Calendar.MODE_GREGORIAN, 1897, 1904),
+ (Calendar.MODE_360, 2001, None),
+ (Calendar.MODE_365, 2001, None),
+ (Calendar.MODE_366, 2001, None),
+ ]
+)
+def test_find_next_leap_year(
+ calendar_mode: str, year: int, expected: Optional[int],
+ patch_calendar_mode
+):
+ patch_calendar_mode(calendar_mode)
+ assert data.find_next_leap_year(year) == expected