From 6e620f6e0175fee6753bb66bf4594dd144794dc7 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 24 Aug 2021 13:22:39 +0100 Subject: [PATCH 01/18] Make TimePoint.seconds_since_unix_epoch return int not str --- metomi/isodatetime/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index ed42c6d..b6c99b6 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -1300,12 +1300,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.""" From ff16a0cc9085992fc257e264875217405462e736 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 24 Aug 2021 13:38:16 +0100 Subject: [PATCH 02/18] Fix type annotations --- metomi/isodatetime/data.py | 94 +++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index b6c99b6..e96fea9 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 Optional + 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,7 +394,7 @@ 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." @@ -412,7 +413,7 @@ 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': return self + -1 * other def __str__(self): @@ -721,37 +722,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 @@ -1357,7 +1357,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 +1368,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 +1380,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 +1402,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 +1410,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 +1421,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() @@ -1545,7 +1545,7 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, new_year_of_century = new._year % 100 return new - def __add__(self, other) -> "TimePoint": + def __add__(self, other) -> 'TimePoint': if isinstance(other, TimePoint): if self._truncated and not other._truncated: new = other.to_time_zone(self._time_zone) @@ -1618,7 +1618,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 +1646,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: @@ -1680,19 +1680,19 @@ 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): From ddc00aff77cefb8a5fa149e033042eeaabd52029 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 3 Nov 2021 13:45:49 +0000 Subject: [PATCH 03/18] Improve math operator dunder methods --- metomi/isodatetime/data.py | 111 +++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 48 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index e96fea9..2b2f38f 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -22,7 +22,7 @@ from functools import lru_cache from math import floor import operator -from typing import Optional +from typing import Optional, Union, overload from . import dumpers from . import timezone @@ -396,10 +396,7 @@ def __eq__(self, other: object) -> bool: 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} @@ -414,6 +411,8 @@ def __add__(self, other: 'Duration') -> 'TimeRecurrence': min_point=self._min_point, max_point=self._max_point) def __sub__(self, other: 'Duration') -> 'TimeRecurrence': + if not isinstance(other, Duration): + return NotImplemented return self + -1 * other def __str__(self): @@ -619,7 +618,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() @@ -641,17 +640,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 @@ -666,33 +675,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): @@ -1545,7 +1550,7 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, new_year_of_century = new._year % 100 return new - def __add__(self, other) -> 'TimePoint': + 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) @@ -1553,10 +1558,12 @@ def __add__(self, other) -> 'TimePoint': 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() @@ -1660,7 +1667,7 @@ def _cmp(self, other: object, 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__: @@ -1695,8 +1702,21 @@ def __gt__(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 +1746,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: From 8cd7489cb76203586478f0ffa77a233127c74ace Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 20 Jan 2022 15:45:52 +0000 Subject: [PATCH 04/18] Tidy & add test --- metomi/isodatetime/data.py | 8 +++----- metomi/isodatetime/tests/test_01.py | 7 +++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index 2b2f38f..16dd138 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -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. diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 7adab88..e562b6b 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -612,6 +612,13 @@ def test_duration_subtract(self): self.assertEqual(test_subtract, end_point, "%s - %s" % (start_point, test_duration)) + def test_duration_is_exact(self): + """Test Duration.is_exact().""" + duration = data.Duration(weeks=1, days=1) + assert duration.is_exact() + for duration in (data.Duration(months=1), data.Duration(years=1)): + assert not duration.is_exact() + def test_timepoint_comparison(self): """Test the TimePoint rich comparison methods and hashing.""" run_comparison_tests(data.TimePoint, get_timepoint_comparison_tests()) From 5288ed8772ca225f6622ddce8377adc5c91cc0e1 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 2 Mar 2022 14:06:37 +0000 Subject: [PATCH 05/18] Actions: update test workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 292f3d3e03525251fb133686a6aae00efda5eb77 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 29 Jul 2022 18:01:57 +0100 Subject: [PATCH 06/18] Add py.typed --- metomi/isodatetime/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 metomi/isodatetime/py.typed diff --git a/metomi/isodatetime/py.typed b/metomi/isodatetime/py.typed new file mode 100644 index 0000000..e69de29 From f43b49fd93adc9a6bb51ae6d087a02b867d3d572 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 22 Jan 2024 18:56:47 +0000 Subject: [PATCH 07/18] Update changelog --- CHANGES.md | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bd25689..9952c67 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,7 +8,13 @@ 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 + +[#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 +25,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 +49,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 +66,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 +89,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 +121,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 +144,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 +155,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 +165,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 +178,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 +193,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 +211,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 +229,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 +246,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 +271,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) From cf038ccb7d80faa7f10bde1ab6cd39e84d99cc41 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 2 Mar 2022 16:50:00 +0000 Subject: [PATCH 08/18] De-unittest.TestCase-ify some tests --- metomi/isodatetime/tests/test_01.py | 135 +++++++++++++++------------- 1 file changed, 71 insertions(+), 64 deletions(-) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index e562b6b..a3695d9 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -619,71 +619,78 @@ def test_duration_is_exact(self): for duration in (data.Duration(months=1), data.Duration(years=1)): assert not duration.is_exact() - 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"]: + +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) + + +def test_timepoint_subtract(): + """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) + assert test_string == ctrl_string + + +def test_timepoint_add_duration(): + """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 + assert seconds_added == t.second_of_minute + + +def test_timepoint_add_duration_without_minute(): + """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 + assert seconds_added == t.second_of_minute + + +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) + assert "out of bounds" in str(exc_info.value) + + +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) - for kwargs in tests["out_of_bounds"]: - with self.assertRaises(BadInputError) as cm: - data.TimePoint(**kwargs) - assert "out of bounds" in str(cm.exception) - - 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) + assert "Conflicting input" in str(exc_info.value) def test_timepoint_without_year(): From bf9ad64fbb66cff9bd6fed8a6b17dc256105a5fe Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 2 Mar 2022 17:15:39 +0000 Subject: [PATCH 09/18] Parametrize test --- metomi/isodatetime/tests/test_01.py | 60 +++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index a3695d9..080f04f 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -18,6 +18,7 @@ # ---------------------------------------------------------------------------- """This tests the ISO 8601 data model functionality.""" +from typing import Union import pytest import unittest @@ -630,8 +631,9 @@ def test_timepoint_comparison(): 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) + 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 @@ -653,24 +655,42 @@ def test_timepoint_subtract(): assert test_string == ctrl_string -def test_timepoint_add_duration(): - """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 - assert seconds_added == t.second_of_minute - - -def test_timepoint_add_duration_without_minute(): - """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 - assert seconds_added == t.second_of_minute +@pytest.mark.parametrize( + 'timepoint, other, expected', + [ + pytest.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 + ), + id="1900-01-01T01:01 + PT5S" + ), + pytest.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 + ), + id="1900-01-01T01 + PT5S" + ), + ] +) +def test_timepoint_add( + timepoint: data.TimePoint, + other: Union[data.Duration, data.TimePoint], + expected: data.TimePoint +): + """Test adding to a timepoint""" + assert timepoint + other == expected + assert other + timepoint == expected def test_timepoint_bounds(): From e0b5e806d5763808eff66c67bb68d8aae881a897 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 2 Mar 2022 17:25:44 +0000 Subject: [PATCH 10/18] Test for adding truncated TimePoint to normal TimePoint --- metomi/isodatetime/tests/test_01.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 080f04f..2e65943 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -681,6 +681,12 @@ def test_timepoint_subtract(): ), id="1900-01-01T01 + PT5S" ), + pytest.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), + id="2000-01-01 + --03-30" + ) ] ) def test_timepoint_add( From 6029ea3b0097aaf1fc539fd1e51c61650c9df688 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 21 Jul 2023 16:36:14 +0100 Subject: [PATCH 11/18] Fix day of month bug when adding a truncated timepoint to a normal timepoint --- metomi/isodatetime/data.py | 42 +++++++++---- metomi/isodatetime/tests/test_01.py | 92 +++++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 23 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index 16dd138..a1799fe 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -1490,6 +1490,7 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, """Returns a copy of this TimePoint with truncated time properties 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 @@ -1509,31 +1510,45 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, while new._hour_of_day != hour_of_day: new._hour_of_day += 1.0 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 + # Find next date that satisfies same day & month as provided + found = False + while not found: + for month, day in iter_months_days( + new._year, new._month_of_year, new._day_of_month + ): + if ( + day_of_month in {None, day} and + month_of_year in {None, month} + ): + new._day_of_month = day + new._month_of_year = month + found = True + break + else: # no break + new._year += 1 + new._month_of_year = 1 + new._day_of_month = 1 + + 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 @@ -1546,6 +1561,7 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, while new_year_of_century != year_of_century: new._year += 1 new_year_of_century = new._year % 100 + return new def __add__(self, other: Union['Duration', 'TimePoint']) -> 'TimePoint': diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 2e65943..659ebd0 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -24,6 +24,7 @@ from metomi.isodatetime import data from metomi.isodatetime.exceptions import BadInputError +from metomi.isodatetime.parsers import TimePointParser def get_timeduration_tests(): @@ -655,10 +656,17 @@ def test_timepoint_subtract(): assert test_string == ctrl_string +def tp_add_param(timepoint, other, expected): + """pytest.param with nicely formatted IDs""" + return pytest.param( + timepoint, other, expected, id=f"{timepoint} + {other} = {expected}" + ) + + @pytest.mark.parametrize( 'timepoint, other, expected', [ - pytest.param( + tp_add_param( data.TimePoint( year=1900, month_of_year=1, day_of_month=1, hour_of_day=1, minute_of_hour=1 @@ -668,9 +676,8 @@ def test_timepoint_subtract(): year=1900, month_of_year=1, day_of_month=1, hour_of_day=1, minute_of_hour=1, second_of_minute=5 ), - id="1900-01-01T01:01 + PT5S" ), - pytest.param( + tp_add_param( data.TimePoint( year=1900, month_of_year=1, day_of_month=1, hour_of_day=1 ), @@ -679,15 +686,56 @@ def test_timepoint_subtract(): year=1900, month_of_year=1, day_of_month=1, hour_of_day=1, second_of_minute=5 ), - id="1900-01-01T01 + PT5S" ), - pytest.param( + tp_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 + ) + ), + tp_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), - id="2000-01-01 + --03-30" - ) - ] + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + ], ) def test_timepoint_add( timepoint: data.TimePoint, @@ -695,8 +743,32 @@ def test_timepoint_add( expected: data.TimePoint ): """Test adding to a timepoint""" - assert timepoint + other == expected - assert other + timepoint == expected + forward = timepoint + other + assert forward == expected + backward = other + timepoint + assert backward == expected + + +@pytest.mark.parametrize( + 'timepoint, other, expected', + [ + tp_add_param( + '1990-04-15T00Z', + '-11-02', + data.TimePoint(year=2011, month_of_year=2, day_of_month=1) + ) + ] +) +def test_timepoint_add__extra( + timepoint: str, other: str, expected: data.TimePoint +): + parser = TimePointParser(allow_truncated=True) + 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(): From cb5ab8e1fccf999ff7fbbb31044d24d37717045f Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 24 Jul 2023 18:09:35 +0100 Subject: [PATCH 12/18] Optimise --- metomi/isodatetime/data.py | 100 ++++++++++++++++++++++------ metomi/isodatetime/tests/test_01.py | 36 +++++++++- 2 files changed, 115 insertions(+), 21 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index a1799fe..5a1b5f2 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -22,7 +22,7 @@ from functools import lru_cache from math import floor import operator -from typing import Optional, Union, overload +from typing import List, Optional, Union, cast, overload from . import dumpers from . import timezone @@ -1524,24 +1524,8 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, if day_of_month or month_of_year: new = new.to_calendar_date() - # Find next date that satisfies same day & month as provided - found = False - while not found: - for month, day in iter_months_days( - new._year, new._month_of_year, new._day_of_month - ): - if ( - day_of_month in {None, day} and - month_of_year in {None, month} - ): - new._day_of_month = day - new._month_of_year = month - found = True - break - else: # no break - new._year += 1 - new._month_of_year = 1 - new._day_of_month = 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() @@ -1564,6 +1548,72 @@ def add_truncated(self, year_of_century=None, year_of_decade=None, return new + @overload + def find_next_month_and_day( + self, month: int, day: Optional[int] + ) -> 'TimePoint': + ... + + @overload + def find_next_month_and_day( + self, month: Optional[int], day: int + ) -> 'TimePoint': + ... + + def find_next_month_and_day( + self, month: Optional[int], day: Optional[int] + ) -> 'TimePoint': + """Return the next TimePoint after this one (inclusive) that has the + same month and/or day as specified. + + Args: + month: month of year. + day: day of month. + """ + new = self._copy() + new._next_month_and_day(month, day) + return new + + def _next_month_and_day( + self, month: Optional[int], day: Optional[int] + ) -> None: + """Implementation of find_next_month_and_day(). + + WARNING: mutates self instance. + """ + years_to_check: List[int] = [self._year, self._year + 1] + for i, year in enumerate(years_to_check): + self._year = year + if month: + if month >= self._month_of_year and ( + day is None or + self._day_of_month <= day <= get_days_in_month(month, year) + ): + self._month_of_year = month + self._day_of_month = day or 1 + 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: @@ -2154,6 +2204,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) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 659ebd0..1f6defe 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -18,15 +18,27 @@ # ---------------------------------------------------------------------------- """This tests the ISO 8601 data model functionality.""" -from typing import Union +from typing import Optional, Union import pytest import unittest from metomi.isodatetime import data +from metomi.isodatetime.data import Calendar 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 get_timeduration_tests(): """Yield tests for the duration class.""" tests = { @@ -756,7 +768,7 @@ def test_timepoint_add( '1990-04-15T00Z', '-11-02', data.TimePoint(year=2011, month_of_year=2, day_of_month=1) - ) + ), ] ) def test_timepoint_add__extra( @@ -801,3 +813,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 From 412a0c1945db49e1895782cdc96cf5f7caa5de9d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 25 Jul 2023 12:11:54 +0100 Subject: [PATCH 13/18] Tidy --- metomi/isodatetime/data.py | 63 +++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index 5a1b5f2..0ae9e4f 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -22,7 +22,7 @@ from functools import lru_cache from math import floor import operator -from typing import List, Optional, Union, cast, overload +from typing import List, Optional, Tuple, Union, cast, overload from . import dumpers from . import timezone @@ -1483,10 +1483,19 @@ def get_truncated_properties(self): props.update({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): + def add_truncated( + self, + year_of_century: Optional[int] = None, + year_of_decade: Optional[int] = None, + month_of_year: Optional[int] = None, + week_of_year: Optional[int] = None, + day_of_year: Optional[int] = None, + day_of_month: Optional[int] = None, + day_of_week: Optional[int] = None, + hour_of_day: Optional[int] = None, + minute_of_hour: Optional[int] = None, + second_of_minute: Optional[int] = None + ): """Returns a copy of this TimePoint with truncated time properties added to it.""" new = self._copy() @@ -1958,8 +1967,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 @@ -1973,17 +1983,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 @@ -2599,16 +2610,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( @@ -2616,8 +2632,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 From aa1c20740344e1f8c5ba47952ac7394d0929c046 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:10:28 +0000 Subject: [PATCH 14/18] Fix bug where adding truncated TimePoint to a TimePoint could result in prior datetime Truncated timepoint addition now correctly assumes unspecified units smaller than the largest specified unit are at their minimal values `T00` is short for `T0000` and so `---01` should be short for `01T0000`, `--04` should be short for `--04-01T0000` etc. --- metomi/isodatetime/data.py | 167 ++++++++++++++-------------- metomi/isodatetime/tests/test_01.py | 107 +++++++++++++++++- 2 files changed, 192 insertions(+), 82 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index 0ae9e4f..533e967 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -22,7 +22,7 @@ from functools import lru_cache from math import floor import operator -from typing import List, Optional, Tuple, Union, cast, overload +from typing import Dict, List, Optional, Tuple, Union, cast, overload from . import dumpers from . import timezone @@ -1433,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 @@ -1466,45 +1461,53 @@ 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: Optional[int] = None, - year_of_decade: Optional[int] = None, - month_of_year: Optional[int] = None, - week_of_year: Optional[int] = None, - day_of_year: Optional[int] = None, - day_of_month: Optional[int] = None, - day_of_week: Optional[int] = None, - hour_of_day: Optional[int] = None, - minute_of_hour: Optional[int] = None, - second_of_minute: Optional[int] = 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() + 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 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 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: @@ -1542,64 +1545,66 @@ def add_truncated( new._day_of_year += 1 new._tick_over() - if year_of_decade 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_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: - 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 - - return new + if day_of_month is None: + new._day_of_month = 1 + if month_of_year is None: + new._month_of_year = 1 - @overload - def find_next_month_and_day( - self, month: int, day: Optional[int] - ) -> 'TimePoint': - ... - - @overload - def find_next_month_and_day( - self, month: Optional[int], day: int - ) -> 'TimePoint': - ... - - def find_next_month_and_day( - self, month: Optional[int], day: Optional[int] - ) -> 'TimePoint': - """Return the next TimePoint after this one (inclusive) that has the - same month and/or day as specified. + 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 - Args: - month: month of year. - day: day of month. - """ - new = self._copy() - new._next_month_and_day(month, day) return new def _next_month_and_day( self, month: Optional[int], day: Optional[int] ) -> None: - """Implementation of find_next_month_and_day(). + """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 month >= self._month_of_year and ( - day is None or - self._day_of_month <= day <= get_days_in_month(month, year) + 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 or 1 + self._day_of_month = day return else: for month_ in range( @@ -1618,7 +1623,7 @@ def _next_month_and_day( # 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)) + years_to_check.append(cast('int', next_leap_year)) raise ValueError( f"Invalid month of year {month} or day of month {day}" ) @@ -1627,7 +1632,7 @@ 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 diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 1f6defe..82d2ba4 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -707,6 +707,11 @@ def tp_add_param(timepoint, other, expected): hour_of_day=3, minute_of_hour=32 ) ), + tp_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), + ), tp_add_param( data.TimePoint(year=2000), data.TimePoint(month_of_year=3, day_of_month=30, truncated=True), @@ -717,6 +722,11 @@ def tp_add_param(timepoint, other, expected): data.TimePoint(month_of_year=2, day_of_month=15, truncated=True), data.TimePoint(year=2000, month_of_year=2, day_of_month=15), ), + tp_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), + ), tp_add_param( data.TimePoint(year=2000, day_of_month=15), data.TimePoint(month_of_year=1, day_of_month=15, truncated=True), @@ -732,6 +742,23 @@ def tp_add_param(timepoint, other, expected): data.TimePoint(day_of_month=14, truncated=True), data.TimePoint(year=2000, month_of_year=2, day_of_month=14), ), + tp_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), + ), + tp_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), + ), + tp_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, + ), + ), tp_add_param( data.TimePoint(year=2001, month_of_year=2), data.TimePoint(day_of_month=31, truncated=True), @@ -747,6 +774,59 @@ def tp_add_param(timepoint, other, expected): data.TimePoint(month_of_year=3, truncated=True), data.TimePoint(year=2001, month_of_year=3, day_of_month=1), ), + tp_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), + ), + tp_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), + ), + tp_add_param( + data.TimePoint(year=2004), + data.TimePoint(hour_of_day=3, truncated=True), + data.TimePoint(year=2004, hour_of_day=3), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), + tp_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), + ), ], ) def test_timepoint_add( @@ -769,12 +849,37 @@ def test_timepoint_add( '-11-02', data.TimePoint(year=2011, month_of_year=2, day_of_month=1) ), + tp_add_param( + '2008-01-01T02Z', + '-08', + data.TimePoint(year=2108), + ), + tp_add_param( + '2008-01-01T02Z', + '-08T02Z', + data.TimePoint(year=2008, hour_of_day=2), + ), + tp_add_param( + '2009-01-04T00Z', + '-09', + data.TimePoint(year=2109), + ), + tp_add_param( + '2014-04-12T00Z', + '-14-04', + data.TimePoint(year=2114, month_of_year=4, day_of_month=1) + ), + tp_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) + 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 From 41bc38ff234c903e6ab6ed60e95dcc90b088d9b6 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:21:49 +0000 Subject: [PATCH 15/18] Optimise addition of truncated TimePoint --- metomi/isodatetime/data.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/metomi/isodatetime/data.py b/metomi/isodatetime/data.py index 533e967..faf4771 100644 --- a/metomi/isodatetime/data.py +++ b/metomi/isodatetime/data.py @@ -1511,17 +1511,22 @@ def add_truncated(self, other: 'TimePoint') -> 'TimePoint': 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() From 6d6b7ee05237f37b412c641656529399ace8158e Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 22 Jan 2024 18:58:04 +0000 Subject: [PATCH 16/18] Update changelog --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 9952c67..7575c0b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,9 @@ ones in. --> ### 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`. From f2e769d5dbbf7104644ee6a5373efcf0a14544d0 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 6 Mar 2024 17:50:41 +0000 Subject: [PATCH 17/18] Improve `Duration` tests --- metomi/isodatetime/tests/test_01.py | 200 +++++++++++++++++----------- 1 file changed, 124 insertions(+), 76 deletions(-) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 82d2ba4..0fcc5a4 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -23,7 +23,7 @@ import unittest from metomi.isodatetime import data -from metomi.isodatetime.data import Calendar +from metomi.isodatetime.data import Calendar, Duration, TimePoint from metomi.isodatetime.exceptions import BadInputError from metomi.isodatetime.parsers import TimePointParser @@ -39,6 +39,20 @@ def _patch_calendar_mode(mode: str) -> None: 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(): """Yield tests for the duration class.""" tests = { @@ -593,45 +607,76 @@ 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_duration_is_exact(self): - """Test Duration.is_exact().""" - duration = data.Duration(weeks=1, days=1) - assert duration.is_exact() - for duration in (data.Duration(months=1), data.Duration(years=1)): - assert not duration.is_exact() + +@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_timepoint_comparison(): @@ -668,17 +713,20 @@ def test_timepoint_subtract(): assert test_string == ctrl_string -def tp_add_param(timepoint, other, expected): - """pytest.param with nicely formatted IDs""" - return pytest.param( - timepoint, other, expected, id=f"{timepoint} + {other} = {expected}" - ) +@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', [ - tp_add_param( + add_param( data.TimePoint( year=1900, month_of_year=1, day_of_month=1, hour_of_day=1, minute_of_hour=1 @@ -689,7 +737,7 @@ def tp_add_param(timepoint, other, expected): minute_of_hour=1, second_of_minute=5 ), ), - tp_add_param( + add_param( data.TimePoint( year=1900, month_of_year=1, day_of_month=1, hour_of_day=1 ), @@ -699,7 +747,7 @@ def tp_add_param(timepoint, other, expected): second_of_minute=5 ), ), - tp_add_param( + 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( @@ -707,111 +755,111 @@ def tp_add_param(timepoint, other, expected): hour_of_day=3, minute_of_hour=32 ) ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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, ), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + add_param( data.TimePoint(year=2004), data.TimePoint(hour_of_day=3, truncated=True), data.TimePoint(year=2004, hour_of_day=3), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + 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), ), - tp_add_param( + add_param( data.TimePoint(year=3000), data.TimePoint( year=0, month_of_year=2, day_of_month=29, @@ -819,7 +867,7 @@ def tp_add_param(timepoint, other, expected): ), data.TimePoint(year=3020, month_of_year=2, day_of_month=29), ), - tp_add_param( + add_param( data.TimePoint(year=3000), data.TimePoint( year=0, month_of_year=2, day_of_month=29, @@ -844,32 +892,32 @@ def test_timepoint_add( @pytest.mark.parametrize( 'timepoint, other, expected', [ - tp_add_param( + add_param( '1990-04-15T00Z', '-11-02', data.TimePoint(year=2011, month_of_year=2, day_of_month=1) ), - tp_add_param( + add_param( '2008-01-01T02Z', '-08', data.TimePoint(year=2108), ), - tp_add_param( + add_param( '2008-01-01T02Z', '-08T02Z', data.TimePoint(year=2008, hour_of_day=2), ), - tp_add_param( + add_param( '2009-01-04T00Z', '-09', data.TimePoint(year=2109), ), - tp_add_param( + add_param( '2014-04-12T00Z', '-14-04', data.TimePoint(year=2114, month_of_year=4, day_of_month=1) ), - tp_add_param( + add_param( '2014-04-01T00Z', '-14-04', data.TimePoint(year=2014, month_of_year=4, day_of_month=1) From 96e7f06c4f7007e6da9229ae305492253c4cdf69 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 6 Mar 2024 18:16:20 +0000 Subject: [PATCH 18/18] Plug coverage holes --- metomi/isodatetime/tests/test_01.py | 39 +++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/metomi/isodatetime/tests/test_01.py b/metomi/isodatetime/tests/test_01.py index 0fcc5a4..d406fe2 100644 --- a/metomi/isodatetime/tests/test_01.py +++ b/metomi/isodatetime/tests/test_01.py @@ -679,6 +679,15 @@ def test_duration_is_exact(duration: Duration, expected: bool): 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()) @@ -703,14 +712,25 @@ def test_timepoint_plus_float_time_duration_day_of_month_type(): assert isinstance(time_point.day_of_month, int) -def test_timepoint_subtract(): +@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.""" - 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) - assert test_string == ctrl_string + 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()) @@ -875,6 +895,11 @@ def test_timepoint_duration_subtract(test): ), 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(