duration.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. # -*- coding: utf-8 -*-
  2. # Copyright (c) 2021, Brandon Nielsen
  3. # All rights reserved.
  4. #
  5. # This software may be modified and distributed under the terms
  6. # of the BSD license. See the LICENSE file for details.
  7. from aniso8601 import compat
  8. from aniso8601.builders import TupleBuilder
  9. from aniso8601.builders.python import PythonTimeBuilder
  10. from aniso8601.date import parse_date
  11. from aniso8601.decimalfraction import normalize
  12. from aniso8601.exceptions import ISOFormatError
  13. from aniso8601.resolution import DurationResolution
  14. from aniso8601.time import parse_time
  15. def get_duration_resolution(isodurationstr):
  16. # Valid string formats are:
  17. #
  18. # PnYnMnDTnHnMnS (or any reduced precision equivalent)
  19. # PnW
  20. # P<date>T<time>
  21. isodurationtuple = parse_duration(isodurationstr, builder=TupleBuilder)
  22. if isodurationtuple.TnS is not None:
  23. return DurationResolution.Seconds
  24. if isodurationtuple.TnM is not None:
  25. return DurationResolution.Minutes
  26. if isodurationtuple.TnH is not None:
  27. return DurationResolution.Hours
  28. if isodurationtuple.PnD is not None:
  29. return DurationResolution.Days
  30. if isodurationtuple.PnW is not None:
  31. return DurationResolution.Weeks
  32. if isodurationtuple.PnM is not None:
  33. return DurationResolution.Months
  34. return DurationResolution.Years
  35. def parse_duration(isodurationstr, builder=PythonTimeBuilder):
  36. # Given a string representing an ISO 8601 duration, return a
  37. # a duration built by the given builder. Valid formats are:
  38. #
  39. # PnYnMnDTnHnMnS (or any reduced precision equivalent)
  40. # PnW
  41. # P<date>T<time>
  42. if compat.is_string(isodurationstr) is False:
  43. raise ValueError("Duration must be string.")
  44. if len(isodurationstr) == 0:
  45. raise ISOFormatError(
  46. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  47. )
  48. if isodurationstr[0] != "P":
  49. raise ISOFormatError("ISO 8601 duration must start with a P.")
  50. # If Y, M, D, H, S, or W are in the string,
  51. # assume it is a specified duration
  52. if _has_any_component(isodurationstr, ["Y", "M", "D", "H", "S", "W"]) is True:
  53. parseresult = _parse_duration_prescribed(isodurationstr)
  54. return builder.build_duration(**parseresult)
  55. if isodurationstr.find("T") != -1:
  56. parseresult = _parse_duration_combined(isodurationstr)
  57. return builder.build_duration(**parseresult)
  58. raise ISOFormatError(
  59. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  60. )
  61. def _parse_duration_prescribed(isodurationstr):
  62. # durationstr can be of the form PnYnMnDTnHnMnS or PnW
  63. # Make sure the end character is valid
  64. # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed
  65. if isodurationstr[-1] not in ["Y", "M", "D", "H", "S", "W"]:
  66. raise ISOFormatError("ISO 8601 duration must end with a valid " "character.")
  67. # Make sure only the lowest order element has decimal precision
  68. durationstr = normalize(isodurationstr)
  69. if durationstr.count(".") > 1:
  70. raise ISOFormatError(
  71. "ISO 8601 allows only lowest order element to " "have a decimal fraction."
  72. )
  73. seperatoridx = durationstr.find(".")
  74. if seperatoridx != -1:
  75. remaining = durationstr[seperatoridx + 1 : -1]
  76. # There should only ever be 1 letter after a decimal if there is more
  77. # then one, the string is invalid
  78. if remaining.isdigit() is False:
  79. raise ISOFormatError(
  80. "ISO 8601 duration must end with " "a single valid character."
  81. )
  82. # Do not allow W in combination with other designators
  83. # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable
  84. if (
  85. durationstr.find("W") != -1
  86. and _has_any_component(durationstr, ["Y", "M", "D", "H", "S"]) is True
  87. ):
  88. raise ISOFormatError(
  89. "ISO 8601 week designators may not be combined "
  90. "with other time designators."
  91. )
  92. # Parse the elements of the duration
  93. if durationstr.find("T") == -1:
  94. return _parse_duration_prescribed_notime(durationstr)
  95. return _parse_duration_prescribed_time(durationstr)
  96. def _parse_duration_prescribed_notime(isodurationstr):
  97. # durationstr can be of the form PnYnMnD or PnW
  98. durationstr = normalize(isodurationstr)
  99. yearstr = None
  100. monthstr = None
  101. daystr = None
  102. weekstr = None
  103. weekidx = durationstr.find("W")
  104. yearidx = durationstr.find("Y")
  105. monthidx = durationstr.find("M")
  106. dayidx = durationstr.find("D")
  107. if weekidx != -1:
  108. weekstr = durationstr[1:-1]
  109. elif yearidx != -1 and monthidx != -1 and dayidx != -1:
  110. yearstr = durationstr[1:yearidx]
  111. monthstr = durationstr[yearidx + 1 : monthidx]
  112. daystr = durationstr[monthidx + 1 : -1]
  113. elif yearidx != -1 and monthidx != -1:
  114. yearstr = durationstr[1:yearidx]
  115. monthstr = durationstr[yearidx + 1 : monthidx]
  116. elif yearidx != -1 and dayidx != -1:
  117. yearstr = durationstr[1:yearidx]
  118. daystr = durationstr[yearidx + 1 : dayidx]
  119. elif monthidx != -1 and dayidx != -1:
  120. monthstr = durationstr[1:monthidx]
  121. daystr = durationstr[monthidx + 1 : -1]
  122. elif yearidx != -1:
  123. yearstr = durationstr[1:-1]
  124. elif monthidx != -1:
  125. monthstr = durationstr[1:-1]
  126. elif dayidx != -1:
  127. daystr = durationstr[1:-1]
  128. else:
  129. raise ISOFormatError(
  130. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  131. )
  132. for componentstr in [yearstr, monthstr, daystr, weekstr]:
  133. if componentstr is not None:
  134. if "." in componentstr:
  135. intstr, fractionalstr = componentstr.split(".", 1)
  136. if intstr.isdigit() is False:
  137. raise ISOFormatError(
  138. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  139. )
  140. else:
  141. if componentstr.isdigit() is False:
  142. raise ISOFormatError(
  143. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  144. )
  145. return {"PnY": yearstr, "PnM": monthstr, "PnW": weekstr, "PnD": daystr}
  146. def _parse_duration_prescribed_time(isodurationstr):
  147. # durationstr can be of the form PnYnMnDTnHnMnS
  148. timeidx = isodurationstr.find("T")
  149. datestr = isodurationstr[:timeidx]
  150. timestr = normalize(isodurationstr[timeidx + 1 :])
  151. hourstr = None
  152. minutestr = None
  153. secondstr = None
  154. houridx = timestr.find("H")
  155. minuteidx = timestr.find("M")
  156. secondidx = timestr.find("S")
  157. if houridx != -1 and minuteidx != -1 and secondidx != -1:
  158. hourstr = timestr[0:houridx]
  159. minutestr = timestr[houridx + 1 : minuteidx]
  160. secondstr = timestr[minuteidx + 1 : -1]
  161. elif houridx != -1 and minuteidx != -1:
  162. hourstr = timestr[0:houridx]
  163. minutestr = timestr[houridx + 1 : minuteidx]
  164. elif houridx != -1 and secondidx != -1:
  165. hourstr = timestr[0:houridx]
  166. secondstr = timestr[houridx + 1 : -1]
  167. elif minuteidx != -1 and secondidx != -1:
  168. minutestr = timestr[0:minuteidx]
  169. secondstr = timestr[minuteidx + 1 : -1]
  170. elif houridx != -1:
  171. hourstr = timestr[0:-1]
  172. elif minuteidx != -1:
  173. minutestr = timestr[0:-1]
  174. elif secondidx != -1:
  175. secondstr = timestr[0:-1]
  176. else:
  177. raise ISOFormatError(
  178. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  179. )
  180. for componentstr in [hourstr, minutestr, secondstr]:
  181. if componentstr is not None:
  182. if "." in componentstr:
  183. intstr, fractionalstr = componentstr.split(".", 1)
  184. if intstr.isdigit() is False:
  185. raise ISOFormatError(
  186. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  187. )
  188. else:
  189. if componentstr.isdigit() is False:
  190. raise ISOFormatError(
  191. '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
  192. )
  193. # Parse any date components
  194. durationdict = {"PnY": None, "PnM": None, "PnW": None, "PnD": None}
  195. if len(datestr) > 1:
  196. durationdict = _parse_duration_prescribed_notime(datestr)
  197. durationdict.update({"TnH": hourstr, "TnM": minutestr, "TnS": secondstr})
  198. return durationdict
  199. def _parse_duration_combined(durationstr):
  200. # Period of the form P<date>T<time>
  201. # Split the string in to its component parts
  202. datepart, timepart = durationstr[1:].split("T", 1) # We skip the 'P'
  203. datevalue = parse_date(datepart, builder=TupleBuilder)
  204. timevalue = parse_time(timepart, builder=TupleBuilder)
  205. return {
  206. "PnY": datevalue.YYYY,
  207. "PnM": datevalue.MM,
  208. "PnD": datevalue.DD,
  209. "TnH": timevalue.hh,
  210. "TnM": timevalue.mm,
  211. "TnS": timevalue.ss,
  212. }
  213. def _has_any_component(durationstr, components):
  214. # Given a duration string, and a list of components, returns True
  215. # if any of the listed components are present, False otherwise.
  216. #
  217. # For instance:
  218. # durationstr = 'P1Y'
  219. # components = ['Y', 'M']
  220. #
  221. # returns True
  222. #
  223. # durationstr = 'P1Y'
  224. # components = ['M', 'D']
  225. #
  226. # returns False
  227. for component in components:
  228. if durationstr.find(component) != -1:
  229. return True
  230. return False