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(