Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/isodate/duration.py: 17%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

138 statements  

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]