duration.py 11 KB

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