diff --git a/CHANGELOG.md b/CHANGELOG.md index 427daec2..6344ab10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,27 @@ # Change Log +## Unreleased + +### Added + +- Added `Duration.to_iso8601_string()` method to output a ISO 8601 string representation of a duration. + +### Fixed + +- Fixed Duration.microseconds not returning an int in python2 + + ## [2.0.4] - 2018-10-30 +### Added + +- Added support for parsing padded 2-digit days of the month with `from_format()` + ### Fixed - Fixed `from_format()` not recognizing input strings when the specified pattern had escaped elements. - Fixed missing `x` token for string formatting. - Fixed reading timezone files. -- Added support for parsing padded 2-digit days of the month with `from_format()` - Fixed `from_format()` trying to parse escaped tokens. - Fixed the `z` token timezone parsing in `from_format()` to allow underscores. - Fixed C extensions build errors. diff --git a/pendulum/duration.py b/pendulum/duration.py index 54d113d3..df0ef16b 100644 --- a/pendulum/duration.py +++ b/pendulum/duration.py @@ -9,6 +9,7 @@ from pendulum.utils._compat import decode from .constants import ( + DAYS_PER_WEEK, SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE, @@ -78,7 +79,8 @@ def __new__( if total < 0: m = -1 - self._microseconds = round(total % m * 1e6) + # round returns a float in python2, so ensure stored as an int + self._microseconds = int(round(total % m * 1e6)) self._seconds = abs(int(total)) % SECONDS_PER_DAY * m _days = abs(int(total)) // SECONDS_PER_DAY * m @@ -243,6 +245,58 @@ def _sign(self, value): return 1 + def to_iso8601_string(self): + """ + Return the duration as an ISO8601 string. + + Either: + "PnYnMnDTnHnMnS" + "PnW" - if only weeks are present + """ + rep = "P" + if self._years: + rep += "{}Y".format(self._years) + if self._months: + rep += "{}M".format(self._months) + # days without any specified years, months + days_alone = self._days - (self.years * 365 + self.months * 30) + if days_alone: + rep += "{}D".format(days_alone) + time = "T" + if self.hours: + time += "{}H".format(self.hours) + if self.minutes: + time += "{}M".format(self.minutes) + # TODO weeks + # TODO signs and test + s = "" + if self.remaining_seconds: + s = str(self.remaining_seconds) + if self.microseconds: + # no division to avoid possible floating point errors + if not s: + s = "0" + s += ".{:0>6d}".format(self.microseconds).rstrip("0") + if s: + time += "{}S".format(s) + if len(time) > 1: + rep += time + if len(rep) == 1: + # 0 duration + rep = "PT0S" + # check if PnW representation is suitable + # i.e. only days + # TODO abs? + if ( + days_alone % DAYS_PER_WEEK == 0 + and not self._years + and not self._months + and len(time) == 1 + ): + w = days_alone // DAYS_PER_WEEK + rep = "P{}W".format(w) + return rep + def as_timedelta(self): """ Return the interval as a native timedelta. diff --git a/tests/duration/test_to_iso8601.py b/tests/duration/test_to_iso8601.py new file mode 100644 index 00000000..06bee8cd --- /dev/null +++ b/tests/duration/test_to_iso8601.py @@ -0,0 +1,114 @@ +import pytest + +import pendulum + + +def test_all(): + d = pendulum.duration( + years=2, months=3, days=4, hours=5, minutes=6, seconds=7, microseconds=50 + ) + + expected = "P2Y3M4DT5H6M7.00005S" + assert d.to_iso8601_string() == expected + + +def test_basic(): + d = pendulum.Duration(years=2, months=3, days=4, hours=5, minutes=6, seconds=7) + + expected = "P2Y3M4DT5H6M7S" + assert d.to_iso8601_string() == expected + + +def test_microsecond_alone(): + d = pendulum.duration(microseconds=5) + + expected = "PT0.000005S" + assert d.to_iso8601_string() == expected + + +def test_microsecond_trailing_zeros(): + d = pendulum.duration(microseconds=500) + + expected = "PT0.0005S" + assert d.to_iso8601_string() == expected + + +def test_second_and_microsecond(): + d = pendulum.duration(seconds=50, microseconds=5) + + expected = "PT50.000005S" + assert d.to_iso8601_string() == expected + + +def test_lots_of_days(): + d = pendulum.duration(days=500) + # should not be coverted to months, years as info is lost + + expected = "P500D" + assert d.to_iso8601_string() == expected + + d = pendulum.duration(days=40) + + expected = "P40D" + assert d.to_iso8601_string() == expected + + +@pytest.mark.skip(reason="This test will fail until large changes to normalization") +def test_lots_of_hours(): + # NOTE: this will fail until total_seconds normalization + # no longer occurs + d = pendulum.duration(hours=36) + # should not be coverted to days, as can be different + # depending on daylight savings changes + + expected = "PT36H" + assert d.to_iso8601_string() == expected + + +def test_days_and_months(): + d = pendulum.duration(months=1, days=40) + # not equivalent to P2M10D + + expected = "P1M40D" + assert d.to_iso8601_string() == expected + + +def test_weeks_alone(): + d = pendulum.duration(days=21) + + # Could also validly be P21D + expected = "P3W" + assert d.to_iso8601_string() == expected + + +def test_weeks_and_other(): + d = pendulum.duration(years=2, days=21) + + expected = "P2Y21D" + assert d.to_iso8601_string() == expected + + +def test_weeks_and_time(): + d = pendulum.duration(days=21, minutes=7) + + expected = "P21DT7M" + assert d.to_iso8601_string() == expected + + +def test_empty(): + # NOTE: can't validly test this in isolation, + # as "P0D" and "PT0S" etc. are equally valid + # ISO8601 representations + d = pendulum.duration() + s = d.to_iso8601_string() + # should be something like "PT0S" or "P0D" + parsed = pendulum.parse(s) + + assert parsed.years == 0 + assert parsed.months == 0 + assert parsed.weeks == 0 + assert parsed.remaining_days == 0 + assert parsed.hours == 0 + assert parsed.minutes == 0 + assert parsed.remaining_seconds == 0 + assert parsed.microseconds == 0 diff --git a/tests/parsing/test_parse_iso8601.py b/tests/parsing/test_parse_iso8601.py index 3d2c3ea6..00bce1f5 100644 --- a/tests/parsing/test_parse_iso8601.py +++ b/tests/parsing/test_parse_iso8601.py @@ -160,7 +160,7 @@ def test_parse_ios8601_invalid(): parse_iso8601("2012-W123") # Missing separator -def test_parse_ios8601_duration(): +def test_parse_iso8601_duration(): text = "P2Y3M4DT5H6M7S" parsed = parse_iso8601(text) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 4f50d863..b1236ea8 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -86,6 +86,20 @@ def test_parse_duration(): assert isinstance(duration, pendulum.Duration) assert_duration(duration, 0, 0, 2, 0, 0, 0, 0) + text = "PT0S" + + duration = pendulum.parse(text) + + assert isinstance(duration, pendulum.Duration) + assert_duration(duration, 0, 0, 0, 0, 0, 0, 0) + + text = "P0D" + + duration = pendulum.parse(text) + + assert isinstance(duration, pendulum.Duration) + assert_duration(duration, 0, 0, 0, 0, 0, 0, 0) + def test_parse_interval(): text = "2008-05-11T15:30:00Z/P1Y2M10DT2H30M"