Skip to content

Commit b399d0f

Browse files
committed
Implement Duration.to_iso8601_string() method.
Add relevant tests. Skip test that can't succed with current duration implementation. Fixed bug caused by microseconds being stored as floats on python2. Update CHANGELOG.
1 parent 19d43e8 commit b399d0f

File tree

5 files changed

+199
-3
lines changed

5 files changed

+199
-3
lines changed

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
# Change Log
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- Added `Duration.to_iso8601_string()` method to output a ISO 8601 string representation of a duration.
8+
9+
### Fixed
10+
11+
- Fixed Duration.microseconds not returning an int in python2
12+
13+
314
## [2.0.4] - 2018-10-30
415

16+
### Added
17+
18+
- Added support for parsing padded 2-digit days of the month with `from_format()`
19+
520
### Fixed
621

722
- Fixed `from_format()` not recognizing input strings when the specified pattern had escaped elements.
823
- Fixed missing `x` token for string formatting.
924
- Fixed reading timezone files.
10-
- Added support for parsing padded 2-digit days of the month with `from_format()`
1125
- Fixed `from_format()` trying to parse escaped tokens.
1226
- Fixed the `z` token timezone parsing in `from_format()` to allow underscores.
1327
- Fixed C extensions build errors.

pendulum/duration.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pendulum.utils._compat import decode
1010

1111
from .constants import (
12+
DAYS_PER_WEEK,
1213
SECONDS_PER_DAY,
1314
SECONDS_PER_HOUR,
1415
SECONDS_PER_MINUTE,
@@ -78,7 +79,8 @@ def __new__(
7879
if total < 0:
7980
m = -1
8081

81-
self._microseconds = round(total % m * 1e6)
82+
# round returns a float in python2, so ensure stored as an int
83+
self._microseconds = int(round(total % m * 1e6))
8284
self._seconds = abs(int(total)) % SECONDS_PER_DAY * m
8385

8486
_days = abs(int(total)) // SECONDS_PER_DAY * m
@@ -243,6 +245,58 @@ def _sign(self, value):
243245

244246
return 1
245247

248+
def to_iso8601_string(self):
249+
"""
250+
Return the duration as an ISO8601 string.
251+
252+
Either:
253+
"PnYnMnDTnHnMnS"
254+
"PnW" - if only weeks are present
255+
"""
256+
rep = "P"
257+
if self._years:
258+
rep += "{}Y".format(self._years)
259+
if self._months:
260+
rep += "{}M".format(self._months)
261+
# days without any specified years, months
262+
days_alone = self._days - (self.years * 365 + self.months * 30)
263+
if days_alone:
264+
rep += "{}D".format(days_alone)
265+
time = "T"
266+
if self.hours:
267+
time += "{}H".format(self.hours)
268+
if self.minutes:
269+
time += "{}M".format(self.minutes)
270+
# TODO weeks
271+
# TODO signs and test
272+
s = ""
273+
if self.remaining_seconds:
274+
s = str(self.remaining_seconds)
275+
if self.microseconds:
276+
# no division to avoid possible floating point errors
277+
if not s:
278+
s = "0"
279+
s += ".{:0>6d}".format(self.microseconds).rstrip("0")
280+
if s:
281+
time += "{}S".format(s)
282+
if len(time) > 1:
283+
rep += time
284+
if len(rep) == 1:
285+
# 0 duration
286+
rep = "PT0S"
287+
# check if PnW representation is suitable
288+
# i.e. only days
289+
# TODO abs?
290+
if (
291+
days_alone % DAYS_PER_WEEK == 0
292+
and not self._years
293+
and not self._months
294+
and len(time) == 1
295+
):
296+
w = days_alone // DAYS_PER_WEEK
297+
rep = "P{}W".format(w)
298+
return rep
299+
246300
def as_timedelta(self):
247301
"""
248302
Return the interval as a native timedelta.

tests/duration/test_to_iso8601.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import pytest
2+
3+
import pendulum
4+
5+
6+
def test_all():
7+
d = pendulum.duration(
8+
years=2, months=3, days=4, hours=5, minutes=6, seconds=7, microseconds=50
9+
)
10+
11+
expected = "P2Y3M4DT5H6M7.00005S"
12+
assert d.to_iso8601_string() == expected
13+
14+
15+
def test_basic():
16+
d = pendulum.Duration(years=2, months=3, days=4, hours=5, minutes=6, seconds=7)
17+
18+
expected = "P2Y3M4DT5H6M7S"
19+
assert d.to_iso8601_string() == expected
20+
21+
22+
def test_microsecond_alone():
23+
d = pendulum.duration(microseconds=5)
24+
25+
expected = "PT0.000005S"
26+
assert d.to_iso8601_string() == expected
27+
28+
29+
def test_microsecond_trailing_zeros():
30+
d = pendulum.duration(microseconds=500)
31+
32+
expected = "PT0.0005S"
33+
assert d.to_iso8601_string() == expected
34+
35+
36+
def test_second_and_microsecond():
37+
d = pendulum.duration(seconds=50, microseconds=5)
38+
39+
expected = "PT50.000005S"
40+
assert d.to_iso8601_string() == expected
41+
42+
43+
def test_lots_of_days():
44+
d = pendulum.duration(days=500)
45+
# should not be coverted to months, years as info is lost
46+
47+
expected = "P500D"
48+
assert d.to_iso8601_string() == expected
49+
50+
d = pendulum.duration(days=40)
51+
52+
expected = "P40D"
53+
assert d.to_iso8601_string() == expected
54+
55+
56+
@pytest.mark.skip(reason="This test will fail until large changes to normalization")
57+
def test_lots_of_hours():
58+
# NOTE: this will fail until total_seconds normalization
59+
# no longer occurs
60+
d = pendulum.duration(hours=36)
61+
# should not be coverted to days, as can be different
62+
# depending on daylight savings changes
63+
64+
expected = "PT36H"
65+
assert d.to_iso8601_string() == expected
66+
67+
68+
def test_days_and_months():
69+
d = pendulum.duration(months=1, days=40)
70+
# not equivalent to P2M10D
71+
72+
expected = "P1M40D"
73+
assert d.to_iso8601_string() == expected
74+
75+
76+
def test_weeks_alone():
77+
d = pendulum.duration(days=21)
78+
79+
# Could also validly be P21D
80+
expected = "P3W"
81+
assert d.to_iso8601_string() == expected
82+
83+
84+
def test_weeks_and_other():
85+
d = pendulum.duration(years=2, days=21)
86+
87+
expected = "P2Y21D"
88+
assert d.to_iso8601_string() == expected
89+
90+
91+
def test_weeks_and_time():
92+
d = pendulum.duration(days=21, minutes=7)
93+
94+
expected = "P21DT7M"
95+
assert d.to_iso8601_string() == expected
96+
97+
98+
def test_empty():
99+
# NOTE: can't validly test this in isolation,
100+
# as "P0D" and "PT0S" etc. are equally valid
101+
# ISO8601 representations
102+
d = pendulum.duration()
103+
s = d.to_iso8601_string()
104+
# should be something like "PT0S" or "P0D"
105+
parsed = pendulum.parse(s)
106+
107+
assert parsed.years == 0
108+
assert parsed.months == 0
109+
assert parsed.weeks == 0
110+
assert parsed.remaining_days == 0
111+
assert parsed.hours == 0
112+
assert parsed.minutes == 0
113+
assert parsed.remaining_seconds == 0
114+
assert parsed.microseconds == 0

tests/parsing/test_parse_iso8601.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def test_parse_ios8601_invalid():
160160
parse_iso8601("2012-W123") # Missing separator
161161

162162

163-
def test_parse_ios8601_duration():
163+
def test_parse_iso8601_duration():
164164
text = "P2Y3M4DT5H6M7S"
165165
parsed = parse_iso8601(text)
166166

tests/test_parsing.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ def test_parse_duration():
8686
assert isinstance(duration, pendulum.Duration)
8787
assert_duration(duration, 0, 0, 2, 0, 0, 0, 0)
8888

89+
text = "PT0S"
90+
91+
duration = pendulum.parse(text)
92+
93+
assert isinstance(duration, pendulum.Duration)
94+
assert_duration(duration, 0, 0, 0, 0, 0, 0, 0)
95+
96+
text = "P0D"
97+
98+
duration = pendulum.parse(text)
99+
100+
assert isinstance(duration, pendulum.Duration)
101+
assert_duration(duration, 0, 0, 0, 0, 0, 0, 0)
102+
89103

90104
def test_parse_interval():
91105
text = "2008-05-11T15:30:00Z/P1Y2M10DT2H30M"

0 commit comments

Comments
 (0)