1"""
2This module defines a Duration class.
3
4The class Duration allows to define durations in years and months and can be
5used as limited replacement for timedelta objects.
6"""
7
8from datetime import timedelta
9from decimal import ROUND_FLOOR, Decimal
10
11
12def fquotmod(val, low, high):
13 """
14 A divmod function with boundaries.
15
16 """
17 # assumes that all the maths is done with Decimals.
18 # divmod for Decimal uses truncate instead of floor as builtin
19 # divmod, so we have to do it manually here.
20 a, b = val - low, high - low
21 div = (a / b).to_integral(ROUND_FLOOR)
22 mod = a - div * b
23 # if we were not using Decimal, it would look like this.
24 # div, mod = divmod(val - low, high - low)
25 mod += low
26 return int(div), mod
27
28
29def max_days_in_month(year, month):
30 """
31 Determines the number of days of a specific month in a specific year.
32 """
33 if month in (1, 3, 5, 7, 8, 10, 12):
34 return 31
35 if month in (4, 6, 9, 11):
36 return 30
37 if ((year % 400) == 0) or ((year % 100) != 0) and ((year % 4) == 0):
38 return 29
39 return 28
40
41
42class Duration:
43 """
44 A class which represents a duration.
45
46 The difference to datetime.timedelta is, that this class handles also
47 differences given in years and months.
48 A Duration treats differences given in year, months separately from all
49 other components.
50
51 A Duration can be used almost like any timedelta object, however there
52 are some restrictions:
53 * It is not really possible to compare Durations, because it is unclear,
54 whether a duration of 1 year is bigger than 365 days or not.
55 * Equality is only tested between the two (year, month vs. timedelta)
56 basic components.
57
58 A Duration can also be converted into a datetime object, but this requires
59 a start date or an end date.
60
61 The algorithm to add a duration to a date is defined at
62 http://www.w3.org/TR/xmlschema-2/#adding-durations-to-dateTimes
63 """
64
65 def __init__(
66 self,
67 days=0,
68 seconds=0,
69 microseconds=0,
70 milliseconds=0,
71 minutes=0,
72 hours=0,
73 weeks=0,
74 months=0,
75 years=0,
76 ):
77 """
78 Initialise this Duration instance with the given parameters.
79 """
80 if not isinstance(months, Decimal):
81 months = Decimal(str(months))
82 if not isinstance(years, Decimal):
83 years = Decimal(str(years))
84 self.months = months
85 self.years = years
86 self.tdelta = timedelta(
87 days, seconds, microseconds, milliseconds, minutes, hours, weeks
88 )
89
90 def __getstate__(self):
91 return self.__dict__
92
93 def __setstate__(self, state):
94 self.__dict__.update(state)
95
96 def __getattr__(self, name):
97 """
98 Provide direct access to attributes of included timedelta instance.
99 """
100 return getattr(self.tdelta, name)
101
102 def __str__(self):
103 """
104 Return a string representation of this duration similar to timedelta.
105 """
106 params = []
107 if self.years:
108 params.append("%d years" % self.years)
109 if self.months:
110 fmt = "%d months"
111 if self.months <= 1:
112 fmt = "%d month"
113 params.append(fmt % self.months)
114 params.append(str(self.tdelta))
115 return ", ".join(params)
116
117 def __repr__(self):
118 """
119 Return a string suitable for repr(x) calls.
120 """
121 return "%s.%s(%d, %d, %d, years=%d, months=%d)" % (
122 self.__class__.__module__,
123 self.__class__.__name__,
124 self.tdelta.days,
125 self.tdelta.seconds,
126 self.tdelta.microseconds,
127 self.years,
128 self.months,
129 )
130
131 def __hash__(self):
132 """
133 Return a hash of this instance so that it can be used in, for
134 example, dicts and sets.
135 """
136 return hash((self.tdelta, self.months, self.years))
137
138 def __neg__(self):
139 """
140 A simple unary minus.
141
142 Returns a new Duration instance with all it's negated.
143 """
144 negduration = Duration(years=-self.years, months=-self.months)
145 negduration.tdelta = -self.tdelta
146 return negduration
147
148 def __add__(self, other):
149 """
150 Durations can be added with Duration, timedelta, date and datetime
151 objects.
152 """
153 if isinstance(other, Duration):
154 newduration = Duration(
155 years=self.years + other.years, months=self.months + other.months
156 )
157 newduration.tdelta = self.tdelta + other.tdelta
158 return newduration
159 try:
160 # try anything that looks like a date or datetime
161 # 'other' has attributes year, month, day
162 # and relies on 'timedelta + other' being implemented
163 if not (float(self.years).is_integer() and float(self.months).is_integer()):
164 raise ValueError(
165 "fractional years or months not supported" " for date calculations"
166 )
167 newmonth = other.month + self.months
168 carry, newmonth = fquotmod(newmonth, 1, 13)
169 newyear = other.year + self.years + carry
170 maxdays = max_days_in_month(newyear, newmonth)
171 if other.day > maxdays:
172 newday = maxdays
173 else:
174 newday = other.day
175 newdt = other.replace(
176 year=int(newyear), month=int(newmonth), day=int(newday)
177 )
178 # does a timedelta + date/datetime
179 return self.tdelta + newdt
180 except AttributeError:
181 # other probably was not a date/datetime compatible object
182 pass
183 try:
184 # try if other is a timedelta
185 # relies on timedelta + timedelta supported
186 newduration = Duration(years=self.years, months=self.months)
187 newduration.tdelta = self.tdelta + other
188 return newduration
189 except AttributeError:
190 # ignore ... other probably was not a timedelta compatible object
191 pass
192 # we have tried everything .... return a NotImplemented
193 return NotImplemented
194
195 __radd__ = __add__
196
197 def __mul__(self, other):
198 if isinstance(other, int):
199 newduration = Duration(years=self.years * other, months=self.months * other)
200 newduration.tdelta = self.tdelta * other
201 return newduration
202 return NotImplemented
203
204 __rmul__ = __mul__
205
206 def __sub__(self, other):
207 """
208 It is possible to subtract Duration and timedelta objects from Duration
209 objects.
210 """
211 if isinstance(other, Duration):
212 newduration = Duration(
213 years=self.years - other.years, months=self.months - other.months
214 )
215 newduration.tdelta = self.tdelta - other.tdelta
216 return newduration
217 try:
218 # do maths with our timedelta object ....
219 newduration = Duration(years=self.years, months=self.months)
220 newduration.tdelta = self.tdelta - other
221 return newduration
222 except TypeError:
223 # looks like timedelta - other is not implemented
224 pass
225 return NotImplemented
226
227 def __rsub__(self, other):
228 """
229 It is possible to subtract Duration objects from date, datetime and
230 timedelta objects.
231
232 TODO: there is some weird behaviour in date - timedelta ...
233 if timedelta has seconds or microseconds set, then
234 date - timedelta != date + (-timedelta)
235 for now we follow this behaviour to avoid surprises when mixing
236 timedeltas with Durations, but in case this ever changes in
237 the stdlib we can just do:
238 return -self + other
239 instead of all the current code
240 """
241 if isinstance(other, timedelta):
242 tmpdur = Duration()
243 tmpdur.tdelta = other
244 return tmpdur - self
245 try:
246 # check if other behaves like a date/datetime object
247 # does it have year, month, day and replace?
248 if not (float(self.years).is_integer() and float(self.months).is_integer()):
249 raise ValueError(
250 "fractional years or months not supported" " for date calculations"
251 )
252 newmonth = other.month - self.months
253 carry, newmonth = fquotmod(newmonth, 1, 13)
254 newyear = other.year - self.years + carry
255 maxdays = max_days_in_month(newyear, newmonth)
256 if other.day > maxdays:
257 newday = maxdays
258 else:
259 newday = other.day
260 newdt = other.replace(
261 year=int(newyear), month=int(newmonth), day=int(newday)
262 )
263 return newdt - self.tdelta
264 except AttributeError:
265 # other probably was not compatible with data/datetime
266 pass
267 return NotImplemented
268
269 def __eq__(self, other):
270 """
271 If the years, month part and the timedelta part are both equal, then
272 the two Durations are considered equal.
273 """
274 if isinstance(other, Duration):
275 if (self.years * 12 + self.months) == (
276 other.years * 12 + other.months
277 ) and self.tdelta == other.tdelta:
278 return True
279 return False
280 # check if other con be compared against timedelta object
281 # will raise an AssertionError when optimisation is off
282 if self.years == 0 and self.months == 0:
283 return self.tdelta == other
284 return False
285
286 def __ne__(self, other):
287 """
288 If the years, month part or the timedelta part is not equal, then
289 the two Durations are considered not equal.
290 """
291 if isinstance(other, Duration):
292 if (self.years * 12 + self.months) != (
293 other.years * 12 + other.months
294 ) or self.tdelta != other.tdelta:
295 return True
296 return False
297 # check if other can be compared against timedelta object
298 # will raise an AssertionError when optimisation is off
299 if self.years == 0 and self.months == 0:
300 return self.tdelta != other
301 return True
302
303 def totimedelta(self, start=None, end=None):
304 """
305 Convert this duration into a timedelta object.
306
307 This method requires a start datetime or end datetimem, but raises
308 an exception if both are given.
309 """
310 if start is None and end is None:
311 raise ValueError("start or end required")
312 if start is not None and end is not None:
313 raise ValueError("only start or end allowed")
314 if start is not None:
315 return (start + self) - start
316 return end - (end - self)