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