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