Skip to content

Commit a7ac6fa

Browse files
committed
Fixes arithmetic operations on intervals not returning intervals
1 parent 0e649e8 commit a7ac6fa

File tree

8 files changed

+327
-60
lines changed

8 files changed

+327
-60
lines changed

docs/index.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,20 @@ instances that generated it, so that it can give access to more methods and prop
13161316
10
13171317
13181318
1319+
.. warning::
1320+
1321+
Due to its nature (fixed duration between two datetimes), most arithmetic operations will
1322+
return an ``Interval`` instead of a ``Period``.
1323+
1324+
.. code-block:: python
1325+
1326+
dt1 = Pendulum(2016, 8, 7, 12, 34, 56)
1327+
dt2 = dt1.add(days=6, seconds=34)
1328+
period = Period(dt1, dt2)
1329+
period * 2
1330+
# <Interval [1 week 5 days 1 minute 8 seconds]>
1331+
1332+
13191333
Instantiation
13201334
-------------
13211335

pendulum/interval.py

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,29 @@
22

33
from datetime import timedelta
44

5-
from .mixins.interval import WordableIntervalMixin, AbsoluteIntervalMixin
5+
from .mixins.interval import (
6+
WordableIntervalMixin
7+
)
8+
9+
10+
def _divide_and_round(a, b):
11+
"""divide a by b and round result to the nearest integer
12+
13+
When the ratio is exactly half-way between two integers,
14+
the even integer is returned.
15+
"""
16+
# Based on the reference implementation for divmod_near
17+
# in Objects/longobject.c.
18+
q, r = divmod(a, b)
19+
# round up if either r / b > 0.5, or r / b == 0.5 and q is odd.
20+
# The expression r / b > 0.5 is equivalent to 2 * r > b if b is
21+
# positive, 2 * r < b if b negative.
22+
r *= 2
23+
greater_than_half = r > b if b > 0 else r < b
24+
if greater_than_half or r == b and q % 2 == 1:
25+
q += 1
26+
27+
return q
628

729

830
class BaseInterval(timedelta):
@@ -19,17 +41,26 @@ class BaseInterval(timedelta):
1941
_s = None
2042
_invert = None
2143

22-
def __init__(self, days=0, seconds=0, microseconds=0,
44+
def __new__(cls, days=0, seconds=0, microseconds=0,
2345
milliseconds=0, minutes=0, hours=0, weeks=0):
46+
self = timedelta.__new__(
47+
cls, days, seconds, microseconds,
48+
milliseconds, minutes, hours, weeks
49+
)
50+
51+
# Intuitive normalization
2452
total = self.total_seconds()
53+
2554
m = 1
2655
if total < 0:
2756
m = -1
2857

29-
self._microseconds = total - int(total)
58+
self._microseconds = round(total % 1 * 1e6)
3059
self._seconds = abs(int(total)) % 86400 * m
3160
self._days = abs(int(total)) // 86400 * m
3261

62+
return self
63+
3364
def total_minutes(self):
3465
return self.total_seconds() / 60
3566

@@ -104,6 +135,10 @@ def seconds(self):
104135

105136
return self._s
106137

138+
@property
139+
def microseconds(self):
140+
return self._microseconds
141+
107142
@property
108143
def invert(self):
109144
if self._invert is None:
@@ -138,12 +173,33 @@ def _sign(self, value):
138173

139174
return 1
140175

176+
177+
class Interval(WordableIntervalMixin, BaseInterval):
178+
"""
179+
Replacement for the standard timedelta class.
180+
181+
Provides several improvements over the base class.
182+
"""
183+
184+
@classmethod
185+
def instance(cls, delta):
186+
"""
187+
Creates a Interval from a timedelta
188+
189+
:type delta: timedelta
190+
191+
:rtype: Interval
192+
"""
193+
return cls(days=delta.days, seconds=delta.seconds, microseconds=delta.microseconds)
194+
141195
def __add__(self, other):
142196
if isinstance(other, timedelta):
143197
return self.__class__(seconds=self.total_seconds() + other.total_seconds())
144198

145199
return NotImplemented
146200

201+
__radd__ = __add__
202+
147203
def __sub__(self, other):
148204
if isinstance(other, timedelta):
149205
return self.__class__(seconds=self.total_seconds() - other.total_seconds())
@@ -153,27 +209,94 @@ def __sub__(self, other):
153209
def __neg__(self):
154210
return self.__class__(seconds=-self.total_seconds())
155211

212+
def _to_microseconds(self):
213+
return ((self._days * (24*3600) + self._seconds) * 1000000 +
214+
self._microseconds)
156215

157-
class Interval(WordableIntervalMixin, BaseInterval):
158-
"""
159-
Replacement for the standard timedelta class.
216+
def __mul__(self, other):
217+
if isinstance(other, int):
218+
return self.__class__(seconds=self.total_seconds() * other)
160219

161-
Provides several improvements over the base class.
162-
"""
220+
if isinstance(other, float):
221+
usec = self._to_microseconds()
222+
a, b = other.as_integer_ratio()
163223

164-
@classmethod
165-
def instance(cls, delta):
166-
"""
167-
Creates a Interval from a timedelta
224+
return self.__class__(0, 0, _divide_and_round(usec * a, b))
168225

169-
:type delta: timedelta
226+
return NotImplemented
170227

171-
:rtype: Interval
172-
"""
173-
return cls(days=delta.days, seconds=delta.seconds, microseconds=delta.microseconds)
228+
__rmul__ = __mul__
229+
230+
def __floordiv__(self, other):
231+
if not isinstance(other, (int, timedelta)):
232+
return NotImplemented
233+
234+
usec = self._to_microseconds()
235+
if isinstance(other, timedelta):
236+
return usec // other._to_microseconds()
237+
238+
if isinstance(other, int):
239+
return self.__class__(0, 0, usec // other)
240+
241+
def __truediv__(self, other):
242+
if not isinstance(other, (int, float, timedelta)):
243+
return NotImplemented
244+
245+
usec = self._to_microseconds()
246+
if isinstance(other, timedelta):
247+
return usec / other._to_microseconds()
248+
249+
if isinstance(other, int):
250+
return self.__class__(0, 0, _divide_and_round(usec, other))
251+
252+
if isinstance(other, float):
253+
a, b = other.as_integer_ratio()
254+
255+
return self.__class__(0, 0, _divide_and_round(b * usec, a))
174256

257+
__div__ = __floordiv__
175258

176-
class AbsoluteInterval(AbsoluteIntervalMixin, Interval):
259+
def __mod__(self, other):
260+
if isinstance(other, timedelta):
261+
r = self._to_microseconds() % other._to_microseconds()
262+
263+
return self.__class__(0, 0, r)
264+
265+
return NotImplemented
266+
267+
def __divmod__(self, other):
268+
if isinstance(other, timedelta):
269+
q, r = divmod(self._to_microseconds(),
270+
other._to_microseconds())
271+
272+
return q, self.__class__(0, 0, r)
273+
274+
return NotImplemented
275+
276+
Interval.min = Interval(-999999999)
277+
Interval.max = Interval(days=999999999, hours=23,
278+
minutes=59, seconds=59,
279+
microseconds=999999)
280+
Interval.resolution = Interval(microseconds=1)
281+
282+
283+
class AbsoluteInterval(Interval):
177284
"""
178285
Interval that expresses a time difference in absolute values.
179286
"""
287+
288+
def __new__(cls, days=0, seconds=0, microseconds=0,
289+
milliseconds=0, minutes=0, hours=0, weeks=0):
290+
self = super(AbsoluteInterval, cls).__new__(
291+
cls, days, seconds, microseconds,
292+
milliseconds, minutes, hours, weeks
293+
)
294+
295+
# Intuitive normalization
296+
total = self.total_seconds()
297+
298+
self._microseconds = abs(round(total % 1 * 1e6))
299+
self._seconds = abs(int(total)) % 86400
300+
self._days = abs(int(total)) // 86400
301+
302+
return self

pendulum/mixins/interval.py

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,40 +36,3 @@ def __str__(self):
3636

3737
def __repr__(self):
3838
return '<{0} [{1}]>'.format(self.__class__.__name__, str(self))
39-
40-
41-
class AbsoluteIntervalMixin(object):
42-
43-
def total_seconds(self):
44-
return abs(super(AbsoluteIntervalMixin, self).total_seconds())
45-
46-
@property
47-
def weeks(self):
48-
return abs(super(AbsoluteIntervalMixin, self).weeks)
49-
50-
@property
51-
def days(self):
52-
return abs(super(AbsoluteIntervalMixin, self).days)
53-
54-
@property
55-
def hours(self):
56-
return abs(super(AbsoluteIntervalMixin, self).hours)
57-
58-
@property
59-
def minutes(self):
60-
return abs(super(AbsoluteIntervalMixin, self).minutes)
61-
62-
@property
63-
def seconds(self):
64-
return abs(super(AbsoluteIntervalMixin, self).seconds)
65-
66-
@property
67-
def microseconds(self):
68-
return abs(super(AbsoluteIntervalMixin, self).microseconds)
69-
70-
@property
71-
def invert(self):
72-
return super(AbsoluteIntervalMixin, self).total_seconds() < 0
73-
74-
def _sign(self, value):
75-
return 1

pendulum/period.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import operator
44
from .mixins.interval import WordableIntervalMixin
5-
from .interval import BaseInterval
5+
from .interval import BaseInterval, Interval
66

77

88
class Period(WordableIntervalMixin, BaseInterval):
@@ -103,6 +103,14 @@ def xrange(self, unit):
103103

104104
start = getattr(start, method)(**{unit: 1})
105105

106+
def as_interval(self):
107+
"""
108+
Return the Period as an Interval.
109+
110+
:rtype: Interval
111+
"""
112+
return Interval(seconds=self.total_seconds())
113+
106114
def __iter__(self):
107115
return self.xrange('days')
108116

@@ -113,3 +121,41 @@ def __contains__(self, item):
113121
item = Pendulum.instance(item)
114122

115123
return item.between(self.start, self.end)
124+
125+
def __add__(self, other):
126+
return self.as_interval().__add__(other)
127+
128+
__radd__ = __add__
129+
130+
def __sub__(self, other):
131+
return self.as_interval().__sub__(other)
132+
133+
def __neg__(self):
134+
return self.__class__(self.end, self.start, self._absolute)
135+
136+
def __mul__(self, other):
137+
return self.as_interval().__mul__(other)
138+
139+
__rmul__ = __mul__
140+
141+
def __floordiv__(self, other):
142+
return self.as_interval().__floordiv__(other)
143+
144+
def __truediv__(self, other):
145+
return self.as_interval().__truediv__(other)
146+
147+
__div__ = __floordiv__
148+
149+
def __mod__(self, other):
150+
return self.as_interval().__mod__(other)
151+
152+
def __divmod__(self, other):
153+
return self.as_interval().__divmod__(other)
154+
155+
def __abs__(self):
156+
return self.__class__(self.start, self.end, True)
157+
158+
def __repr__(self):
159+
return '<Period [{} -> {}]>'.format(
160+
self._start, self._end
161+
)

pendulum/tz/local_timezone.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import subprocess
66
import re
7+
from contextlib import contextmanager
78

89
from .timezone import Timezone
910
from .parser import Parser
@@ -13,8 +14,13 @@ class LocalTimezone(object):
1314

1415
_cache = None
1516

17+
_local_timezone = None
18+
1619
@classmethod
1720
def get(cls, force=False):
21+
if cls._local_timezone is not None:
22+
return cls._local_timezone
23+
1824
if cls._cache is None or force:
1925
name = cls.get_local_tz_name()
2026
if isinstance(name, Timezone):
@@ -24,6 +30,24 @@ def get(cls, force=False):
2430

2531
return cls._cache
2632

33+
@classmethod
34+
@contextmanager
35+
def test(cls, mock):
36+
"""
37+
Context manager to temporarily set the local_timezone value.
38+
39+
:type mock: Pendulum or None
40+
"""
41+
cls.set_local_timezone(mock)
42+
43+
yield
44+
45+
cls.set_local_timezone()
46+
47+
@classmethod
48+
def set_local_timezone(cls, local_timezone=None):
49+
cls._local_timezone = local_timezone
50+
2751
@classmethod
2852
def get_local_tz_name(cls):
2953
if sys.platform == 'win32':

0 commit comments

Comments
 (0)