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

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

148 statements  

1# -*- coding: utf-8 -*- 

2 

3# Copyright (c) 2026, Brandon Nielsen 

4# SPDX-License-Identifier: BSD-3-Clause 

5 

6from aniso8601 import compat 

7from aniso8601.builders import TupleBuilder 

8from aniso8601.builders.python import PythonTimeBuilder 

9from aniso8601.date import parse_date 

10from aniso8601.decimalfraction import normalize 

11from aniso8601.exceptions import ISOFormatError 

12from aniso8601.resolution import DurationResolution 

13from aniso8601.time import parse_time 

14 

15 

16def get_duration_resolution(isodurationstr): 

17 # Valid string formats are: 

18 # 

19 # PnYnMnDTnHnMnS (or any reduced precision equivalent) 

20 # PnW 

21 # P<date>T<time> 

22 isodurationtuple = parse_duration(isodurationstr, builder=TupleBuilder) 

23 

24 if isodurationtuple.TnS is not None: 

25 return DurationResolution.Seconds 

26 

27 if isodurationtuple.TnM is not None: 

28 return DurationResolution.Minutes 

29 

30 if isodurationtuple.TnH is not None: 

31 return DurationResolution.Hours 

32 

33 if isodurationtuple.PnD is not None: 

34 return DurationResolution.Days 

35 

36 if isodurationtuple.PnW is not None: 

37 return DurationResolution.Weeks 

38 

39 if isodurationtuple.PnM is not None: 

40 return DurationResolution.Months 

41 

42 return DurationResolution.Years 

43 

44 

45def parse_duration(isodurationstr, builder=PythonTimeBuilder): 

46 # Given a string representing an ISO 8601 duration, return a 

47 # a duration built by the given builder. Valid formats are: 

48 # 

49 # PnYnMnDTnHnMnS (or any reduced precision equivalent) 

50 # PnW 

51 # P<date>T<time> 

52 

53 if compat.is_string(isodurationstr) is False: 

54 raise ValueError("Duration must be string.") 

55 

56 if len(isodurationstr) == 0: 

57 raise ISOFormatError( 

58 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

59 ) 

60 

61 if isodurationstr[0] != "P": 

62 raise ISOFormatError("ISO 8601 duration must start with a P.") 

63 

64 # If Y, M, D, H, S, or W are in the string, 

65 # assume it is a specified duration 

66 if _has_any_component(isodurationstr, ["Y", "M", "D", "H", "S", "W"]) is True: 

67 parseresult = _parse_duration_prescribed(isodurationstr) 

68 return builder.build_duration(**parseresult) 

69 

70 if isodurationstr.find("T") != -1: 

71 parseresult = _parse_duration_combined(isodurationstr) 

72 return builder.build_duration(**parseresult) 

73 

74 raise ISOFormatError( 

75 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

76 ) 

77 

78 

79def _parse_duration_prescribed(isodurationstr): 

80 # durationstr can be of the form PnYnMnDTnHnMnS or PnW 

81 

82 # Make sure the end character is valid 

83 # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed 

84 if isodurationstr[-1] not in ["Y", "M", "D", "H", "S", "W"]: 

85 raise ISOFormatError("ISO 8601 duration must end with a valid character.") 

86 

87 # Make sure only the lowest order element has decimal precision 

88 durationstr = normalize(isodurationstr) 

89 

90 if durationstr.count(".") > 1: 

91 raise ISOFormatError( 

92 "ISO 8601 allows only lowest order element to have a decimal fraction." 

93 ) 

94 

95 seperatoridx = durationstr.find(".") 

96 

97 if seperatoridx != -1: 

98 remaining = durationstr[seperatoridx + 1 : -1] 

99 

100 # There should only ever be 1 letter after a decimal if there is more 

101 # then one, the string is invalid 

102 if remaining.isdigit() is False: 

103 raise ISOFormatError( 

104 "ISO 8601 duration must end with a single valid character." 

105 ) 

106 

107 # Do not allow W in combination with other designators 

108 # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable 

109 if ( 

110 durationstr.find("W") != -1 

111 and _has_any_component(durationstr, ["Y", "M", "D", "H", "S"]) is True 

112 ): 

113 raise ISOFormatError( 

114 "ISO 8601 week designators may not be combined " 

115 "with other time designators." 

116 ) 

117 

118 # Parse the elements of the duration 

119 if durationstr.find("T") == -1: 

120 return _parse_duration_prescribed_notime(durationstr) 

121 

122 return _parse_duration_prescribed_time(durationstr) 

123 

124 

125def _parse_duration_prescribed_notime(isodurationstr): 

126 # durationstr can be of the form PnYnMnD or PnW 

127 

128 durationstr = normalize(isodurationstr) 

129 

130 yearstr = None 

131 monthstr = None 

132 daystr = None 

133 weekstr = None 

134 

135 weekidx = durationstr.find("W") 

136 yearidx = durationstr.find("Y") 

137 monthidx = durationstr.find("M") 

138 dayidx = durationstr.find("D") 

139 

140 if weekidx != -1: 

141 weekstr = durationstr[1:-1] 

142 elif yearidx != -1 and monthidx != -1 and dayidx != -1: 

143 yearstr = durationstr[1:yearidx] 

144 monthstr = durationstr[yearidx + 1 : monthidx] 

145 daystr = durationstr[monthidx + 1 : -1] 

146 elif yearidx != -1 and monthidx != -1: 

147 yearstr = durationstr[1:yearidx] 

148 monthstr = durationstr[yearidx + 1 : monthidx] 

149 elif yearidx != -1 and dayidx != -1: 

150 yearstr = durationstr[1:yearidx] 

151 daystr = durationstr[yearidx + 1 : dayidx] 

152 elif monthidx != -1 and dayidx != -1: 

153 monthstr = durationstr[1:monthidx] 

154 daystr = durationstr[monthidx + 1 : -1] 

155 elif yearidx != -1: 

156 yearstr = durationstr[1:-1] 

157 elif monthidx != -1: 

158 monthstr = durationstr[1:-1] 

159 elif dayidx != -1: 

160 daystr = durationstr[1:-1] 

161 else: 

162 raise ISOFormatError( 

163 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

164 ) 

165 

166 for componentstr in [yearstr, monthstr, daystr, weekstr]: 

167 if componentstr is not None: 

168 if "." in componentstr: 

169 intstr = componentstr.split(".", 1)[0] 

170 

171 if intstr.isdigit() is False: 

172 raise ISOFormatError( 

173 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

174 ) 

175 else: 

176 if componentstr.isdigit() is False: 

177 raise ISOFormatError( 

178 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

179 ) 

180 

181 return {"PnY": yearstr, "PnM": monthstr, "PnW": weekstr, "PnD": daystr} 

182 

183 

184def _parse_duration_prescribed_time(isodurationstr): 

185 # durationstr can be of the form PnYnMnDTnHnMnS 

186 

187 timeidx = isodurationstr.find("T") 

188 

189 datestr = isodurationstr[:timeidx] 

190 timestr = normalize(isodurationstr[timeidx + 1 :]) 

191 

192 hourstr = None 

193 minutestr = None 

194 secondstr = None 

195 

196 houridx = timestr.find("H") 

197 minuteidx = timestr.find("M") 

198 secondidx = timestr.find("S") 

199 

200 if houridx != -1 and minuteidx != -1 and secondidx != -1: 

201 hourstr = timestr[0:houridx] 

202 minutestr = timestr[houridx + 1 : minuteidx] 

203 secondstr = timestr[minuteidx + 1 : -1] 

204 elif houridx != -1 and minuteidx != -1: 

205 hourstr = timestr[0:houridx] 

206 minutestr = timestr[houridx + 1 : minuteidx] 

207 elif houridx != -1 and secondidx != -1: 

208 hourstr = timestr[0:houridx] 

209 secondstr = timestr[houridx + 1 : -1] 

210 elif minuteidx != -1 and secondidx != -1: 

211 minutestr = timestr[0:minuteidx] 

212 secondstr = timestr[minuteidx + 1 : -1] 

213 elif houridx != -1: 

214 hourstr = timestr[0:-1] 

215 elif minuteidx != -1: 

216 minutestr = timestr[0:-1] 

217 elif secondidx != -1: 

218 secondstr = timestr[0:-1] 

219 else: 

220 raise ISOFormatError( 

221 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

222 ) 

223 

224 for componentstr in [hourstr, minutestr, secondstr]: 

225 if componentstr is not None: 

226 if "." in componentstr: 

227 intstr = componentstr.split(".", 1)[0] 

228 

229 if intstr.isdigit() is False: 

230 raise ISOFormatError( 

231 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

232 ) 

233 else: 

234 if componentstr.isdigit() is False: 

235 raise ISOFormatError( 

236 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr) 

237 ) 

238 

239 # Parse any date components 

240 durationdict = {"PnY": None, "PnM": None, "PnW": None, "PnD": None} 

241 

242 if len(datestr) > 1: 

243 durationdict = _parse_duration_prescribed_notime(datestr) 

244 

245 durationdict.update({"TnH": hourstr, "TnM": minutestr, "TnS": secondstr}) 

246 

247 return durationdict 

248 

249 

250def _parse_duration_combined(durationstr): 

251 # Period of the form P<date>T<time> 

252 

253 # Split the string in to its component parts 

254 datepart, timepart = durationstr[1:].split("T", 1) # We skip the 'P' 

255 

256 datevalue = parse_date(datepart, builder=TupleBuilder) 

257 timevalue = parse_time(timepart, builder=TupleBuilder) 

258 

259 return { 

260 "PnY": datevalue.YYYY, 

261 "PnM": datevalue.MM, 

262 "PnD": datevalue.DD, 

263 "TnH": timevalue.hh, 

264 "TnM": timevalue.mm, 

265 "TnS": timevalue.ss, 

266 } 

267 

268 

269def _has_any_component(durationstr, components): 

270 # Given a duration string, and a list of components, returns True 

271 # if any of the listed components are present, False otherwise. 

272 # 

273 # For instance: 

274 # durationstr = 'P1Y' 

275 # components = ['Y', 'M'] 

276 # 

277 # returns True 

278 # 

279 # durationstr = 'P1Y' 

280 # components = ['M', 'D'] 

281 # 

282 # returns False 

283 

284 for component in components: 

285 if durationstr.find(component) != -1: 

286 return True 

287 

288 return False