Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/aniso8601/builders/__init__.py: 91%

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

237 statements  

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

2 

3# Copyright (c) 2026, Brandon Nielsen 

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

5 

6import calendar 

7from collections import namedtuple 

8 

9from aniso8601.exceptions import ( 

10 DayOutOfBoundsError, 

11 HoursOutOfBoundsError, 

12 ISOFormatError, 

13 LeapSecondError, 

14 MidnightBoundsError, 

15 MinutesOutOfBoundsError, 

16 MonthOutOfBoundsError, 

17 SecondsOutOfBoundsError, 

18 WeekOutOfBoundsError, 

19 YearOutOfBoundsError, 

20) 

21 

22DateTuple = namedtuple("Date", ["YYYY", "MM", "DD", "Www", "D", "DDD"]) 

23TimeTuple = namedtuple("Time", ["hh", "mm", "ss", "tz"]) 

24DatetimeTuple = namedtuple("Datetime", ["date", "time"]) 

25DurationTuple = namedtuple( 

26 "Duration", ["PnY", "PnM", "PnW", "PnD", "TnH", "TnM", "TnS"] 

27) 

28IntervalTuple = namedtuple("Interval", ["start", "end", "duration"]) 

29RepeatingIntervalTuple = namedtuple("RepeatingInterval", ["R", "Rnn", "interval"]) 

30TimezoneTuple = namedtuple("Timezone", ["negative", "Z", "hh", "mm", "name"]) 

31 

32Limit = namedtuple( 

33 "Limit", 

34 [ 

35 "casterrorstring", 

36 "min", 

37 "max", 

38 "rangeexception", 

39 "rangeerrorstring", 

40 "rangefunc", 

41 ], 

42) 

43 

44 

45def cast( 

46 value, 

47 castfunction, 

48 caughtexceptions=(ValueError,), 

49 thrownexception=ISOFormatError, 

50 thrownmessage=None, 

51): 

52 try: 

53 result = castfunction(value) 

54 except caughtexceptions: 

55 raise thrownexception(thrownmessage) 

56 

57 return result 

58 

59 

60def range_check(valuestr, limit): 

61 # Returns cast value if in range, raises defined exceptions on failure 

62 if valuestr is None: 

63 return None 

64 

65 if "." in valuestr: 

66 castfunc = float 

67 else: 

68 castfunc = int 

69 

70 value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring) 

71 

72 if limit.min is not None and value < limit.min: 

73 raise limit.rangeexception(limit.rangeerrorstring) 

74 

75 if limit.max is not None and value > limit.max: 

76 raise limit.rangeexception(limit.rangeerrorstring) 

77 

78 return value 

79 

80 

81class BaseTimeBuilder(object): 

82 # Limit tuple format cast function, cast error string, 

83 # lower limit, upper limit, limit error string 

84 DATE_YYYY_LIMIT = Limit( 

85 "Invalid year string.", 

86 0000, 

87 9999, 

88 YearOutOfBoundsError, 

89 "Year must be between 1..9999.", 

90 range_check, 

91 ) 

92 DATE_MM_LIMIT = Limit( 

93 "Invalid month string.", 

94 1, 

95 12, 

96 MonthOutOfBoundsError, 

97 "Month must be between 1..12.", 

98 range_check, 

99 ) 

100 DATE_DD_LIMIT = Limit( 

101 "Invalid day string.", 

102 1, 

103 31, 

104 DayOutOfBoundsError, 

105 "Day must be between 1..31.", 

106 range_check, 

107 ) 

108 DATE_WWW_LIMIT = Limit( 

109 "Invalid week string.", 

110 1, 

111 53, 

112 WeekOutOfBoundsError, 

113 "Week number must be between 1..53.", 

114 range_check, 

115 ) 

116 DATE_D_LIMIT = Limit( 

117 "Invalid weekday string.", 

118 1, 

119 7, 

120 DayOutOfBoundsError, 

121 "Weekday number must be between 1..7.", 

122 range_check, 

123 ) 

124 DATE_DDD_LIMIT = Limit( 

125 "Invalid ordinal day string.", 

126 1, 

127 366, 

128 DayOutOfBoundsError, 

129 "Ordinal day must be between 1..366.", 

130 range_check, 

131 ) 

132 TIME_HH_LIMIT = Limit( 

133 "Invalid hour string.", 

134 0, 

135 24, 

136 HoursOutOfBoundsError, 

137 "Hour must be between 0..24 with 24 representing midnight.", 

138 range_check, 

139 ) 

140 TIME_MM_LIMIT = Limit( 

141 "Invalid minute string.", 

142 0, 

143 59, 

144 MinutesOutOfBoundsError, 

145 "Minute must be between 0..59.", 

146 range_check, 

147 ) 

148 TIME_SS_LIMIT = Limit( 

149 "Invalid second string.", 

150 0, 

151 60, 

152 SecondsOutOfBoundsError, 

153 "Second must be between 0..60 with 60 representing a leap second.", 

154 range_check, 

155 ) 

156 TZ_HH_LIMIT = Limit( 

157 "Invalid timezone hour string.", 

158 0, 

159 23, 

160 HoursOutOfBoundsError, 

161 "Hour must be between 0..23.", 

162 range_check, 

163 ) 

164 TZ_MM_LIMIT = Limit( 

165 "Invalid timezone minute string.", 

166 0, 

167 59, 

168 MinutesOutOfBoundsError, 

169 "Minute must be between 0..59.", 

170 range_check, 

171 ) 

172 DURATION_PNY_LIMIT = Limit( 

173 "Invalid year duration string.", 

174 0, 

175 None, 

176 ISOFormatError, 

177 "Duration years component must be positive.", 

178 range_check, 

179 ) 

180 DURATION_PNM_LIMIT = Limit( 

181 "Invalid month duration string.", 

182 0, 

183 None, 

184 ISOFormatError, 

185 "Duration months component must be positive.", 

186 range_check, 

187 ) 

188 DURATION_PNW_LIMIT = Limit( 

189 "Invalid week duration string.", 

190 0, 

191 None, 

192 ISOFormatError, 

193 "Duration weeks component must be positive.", 

194 range_check, 

195 ) 

196 DURATION_PND_LIMIT = Limit( 

197 "Invalid day duration string.", 

198 0, 

199 None, 

200 ISOFormatError, 

201 "Duration days component must be positive.", 

202 range_check, 

203 ) 

204 DURATION_TNH_LIMIT = Limit( 

205 "Invalid hour duration string.", 

206 0, 

207 None, 

208 ISOFormatError, 

209 "Duration hours component must be positive.", 

210 range_check, 

211 ) 

212 DURATION_TNM_LIMIT = Limit( 

213 "Invalid minute duration string.", 

214 0, 

215 None, 

216 ISOFormatError, 

217 "Duration minutes component must be positive.", 

218 range_check, 

219 ) 

220 DURATION_TNS_LIMIT = Limit( 

221 "Invalid second duration string.", 

222 0, 

223 None, 

224 ISOFormatError, 

225 "Duration seconds component must be positive.", 

226 range_check, 

227 ) 

228 INTERVAL_RNN_LIMIT = Limit( 

229 "Invalid duration repetition string.", 

230 0, 

231 None, 

232 ISOFormatError, 

233 "Duration repetition count must be positive.", 

234 range_check, 

235 ) 

236 

237 DATE_RANGE_DICT = { 

238 "YYYY": DATE_YYYY_LIMIT, 

239 "MM": DATE_MM_LIMIT, 

240 "DD": DATE_DD_LIMIT, 

241 "Www": DATE_WWW_LIMIT, 

242 "D": DATE_D_LIMIT, 

243 "DDD": DATE_DDD_LIMIT, 

244 } 

245 

246 TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT} 

247 

248 DURATION_RANGE_DICT = { 

249 "PnY": DURATION_PNY_LIMIT, 

250 "PnM": DURATION_PNM_LIMIT, 

251 "PnW": DURATION_PNW_LIMIT, 

252 "PnD": DURATION_PND_LIMIT, 

253 "TnH": DURATION_TNH_LIMIT, 

254 "TnM": DURATION_TNM_LIMIT, 

255 "TnS": DURATION_TNS_LIMIT, 

256 } 

257 

258 REPEATING_INTERVAL_RANGE_DICT = {"Rnn": INTERVAL_RNN_LIMIT} 

259 

260 TIMEZONE_RANGE_DICT = {"hh": TZ_HH_LIMIT, "mm": TZ_MM_LIMIT} 

261 

262 LEAP_SECONDS_SUPPORTED = False 

263 

264 @classmethod 

265 def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None): 

266 raise NotImplementedError 

267 

268 @classmethod 

269 def build_time(cls, hh=None, mm=None, ss=None, tz=None): 

270 raise NotImplementedError 

271 

272 @classmethod 

273 def build_datetime(cls, date, time): 

274 raise NotImplementedError 

275 

276 @classmethod 

277 def build_duration( 

278 cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None 

279 ): 

280 raise NotImplementedError 

281 

282 @classmethod 

283 def build_interval(cls, start=None, end=None, duration=None): 

284 # start, end, and duration are all tuples 

285 raise NotImplementedError 

286 

287 @classmethod 

288 def build_repeating_interval(cls, R=None, Rnn=None, interval=None): 

289 # interval is a tuple 

290 raise NotImplementedError 

291 

292 @classmethod 

293 def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""): 

294 raise NotImplementedError 

295 

296 @classmethod 

297 def range_check_date( 

298 cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None, rangedict=None 

299 ): 

300 if rangedict is None: 

301 rangedict = cls.DATE_RANGE_DICT 

302 

303 if "YYYY" in rangedict: 

304 YYYY = rangedict["YYYY"].rangefunc(YYYY, rangedict["YYYY"]) 

305 

306 if "MM" in rangedict: 

307 MM = rangedict["MM"].rangefunc(MM, rangedict["MM"]) 

308 

309 if "DD" in rangedict: 

310 DD = rangedict["DD"].rangefunc(DD, rangedict["DD"]) 

311 

312 if "Www" in rangedict: 

313 Www = rangedict["Www"].rangefunc(Www, rangedict["Www"]) 

314 

315 if "D" in rangedict: 

316 D = rangedict["D"].rangefunc(D, rangedict["D"]) 

317 

318 if "DDD" in rangedict: 

319 DDD = rangedict["DDD"].rangefunc(DDD, rangedict["DDD"]) 

320 

321 if DD is not None: 

322 # Check calendar 

323 if DD > calendar.monthrange(YYYY, MM)[1]: 

324 raise DayOutOfBoundsError( 

325 "{0} is out of range for {1}-{2}".format(DD, YYYY, MM) 

326 ) 

327 

328 if DDD is not None: 

329 if calendar.isleap(YYYY) is False and DDD == 366: 

330 raise DayOutOfBoundsError( 

331 "{0} is only valid for leap year.".format(DDD) 

332 ) 

333 

334 return (YYYY, MM, DD, Www, D, DDD) 

335 

336 @classmethod 

337 def range_check_time(cls, hh=None, mm=None, ss=None, tz=None, rangedict=None): 

338 # Used for midnight and leap second handling 

339 midnight = False # Handle hh = '24' specially 

340 

341 if rangedict is None: 

342 rangedict = cls.TIME_RANGE_DICT 

343 

344 if "hh" in rangedict: 

345 try: 

346 hh = rangedict["hh"].rangefunc(hh, rangedict["hh"]) 

347 except HoursOutOfBoundsError as e: 

348 if float(hh) > 24 and float(hh) < 25: 

349 raise MidnightBoundsError("Hour 24 may only represent midnight.") 

350 

351 raise e 

352 

353 if "mm" in rangedict: 

354 mm = rangedict["mm"].rangefunc(mm, rangedict["mm"]) 

355 

356 if "ss" in rangedict: 

357 ss = rangedict["ss"].rangefunc(ss, rangedict["ss"]) 

358 

359 if hh is not None and hh == 24: 

360 midnight = True 

361 

362 # Handle midnight range 

363 if midnight is True and ( 

364 (mm is not None and mm != 0) or (ss is not None and ss != 0) 

365 ): 

366 raise MidnightBoundsError("Hour 24 may only represent midnight.") 

367 

368 if cls.LEAP_SECONDS_SUPPORTED is True: 

369 if hh != 23 and mm != 59 and ss == 60: 

370 raise cls.TIME_SS_LIMIT.rangeexception( 

371 cls.TIME_SS_LIMIT.rangeerrorstring 

372 ) 

373 else: 

374 if hh == 23 and mm == 59 and ss == 60: 

375 # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is 

376 raise LeapSecondError("Leap seconds are not supported.") 

377 

378 if ss == 60: 

379 raise cls.TIME_SS_LIMIT.rangeexception( 

380 cls.TIME_SS_LIMIT.rangeerrorstring 

381 ) 

382 

383 return (hh, mm, ss, tz) 

384 

385 @classmethod 

386 def range_check_duration( 

387 cls, 

388 PnY=None, 

389 PnM=None, 

390 PnW=None, 

391 PnD=None, 

392 TnH=None, 

393 TnM=None, 

394 TnS=None, 

395 rangedict=None, 

396 ): 

397 if rangedict is None: 

398 rangedict = cls.DURATION_RANGE_DICT 

399 

400 if "PnY" in rangedict: 

401 PnY = rangedict["PnY"].rangefunc(PnY, rangedict["PnY"]) 

402 

403 if "PnM" in rangedict: 

404 PnM = rangedict["PnM"].rangefunc(PnM, rangedict["PnM"]) 

405 

406 if "PnW" in rangedict: 

407 PnW = rangedict["PnW"].rangefunc(PnW, rangedict["PnW"]) 

408 

409 if "PnD" in rangedict: 

410 PnD = rangedict["PnD"].rangefunc(PnD, rangedict["PnD"]) 

411 

412 if "TnH" in rangedict: 

413 TnH = rangedict["TnH"].rangefunc(TnH, rangedict["TnH"]) 

414 

415 if "TnM" in rangedict: 

416 TnM = rangedict["TnM"].rangefunc(TnM, rangedict["TnM"]) 

417 

418 if "TnS" in rangedict: 

419 TnS = rangedict["TnS"].rangefunc(TnS, rangedict["TnS"]) 

420 

421 return (PnY, PnM, PnW, PnD, TnH, TnM, TnS) 

422 

423 @classmethod 

424 def range_check_repeating_interval( 

425 cls, R=None, Rnn=None, interval=None, rangedict=None 

426 ): 

427 if rangedict is None: 

428 rangedict = cls.REPEATING_INTERVAL_RANGE_DICT 

429 

430 if "Rnn" in rangedict: 

431 Rnn = rangedict["Rnn"].rangefunc(Rnn, rangedict["Rnn"]) 

432 

433 return (R, Rnn, interval) 

434 

435 @classmethod 

436 def range_check_timezone( 

437 cls, negative=None, Z=None, hh=None, mm=None, name="", rangedict=None 

438 ): 

439 if rangedict is None: 

440 rangedict = cls.TIMEZONE_RANGE_DICT 

441 

442 if "hh" in rangedict: 

443 hh = rangedict["hh"].rangefunc(hh, rangedict["hh"]) 

444 

445 if "mm" in rangedict: 

446 mm = rangedict["mm"].rangefunc(mm, rangedict["mm"]) 

447 

448 return (negative, Z, hh, mm, name) 

449 

450 @classmethod 

451 def _build_object(cls, parsetuple): 

452 # Given a TupleBuilder tuple, build the correct object 

453 if isinstance(parsetuple, DateTuple): 

454 return cls.build_date( 

455 YYYY=parsetuple.YYYY, 

456 MM=parsetuple.MM, 

457 DD=parsetuple.DD, 

458 Www=parsetuple.Www, 

459 D=parsetuple.D, 

460 DDD=parsetuple.DDD, 

461 ) 

462 

463 if isinstance(parsetuple, TimeTuple): 

464 return cls.build_time( 

465 hh=parsetuple.hh, mm=parsetuple.mm, ss=parsetuple.ss, tz=parsetuple.tz 

466 ) 

467 

468 if isinstance(parsetuple, DatetimeTuple): 

469 return cls.build_datetime(parsetuple.date, parsetuple.time) 

470 

471 if isinstance(parsetuple, DurationTuple): 

472 return cls.build_duration( 

473 PnY=parsetuple.PnY, 

474 PnM=parsetuple.PnM, 

475 PnW=parsetuple.PnW, 

476 PnD=parsetuple.PnD, 

477 TnH=parsetuple.TnH, 

478 TnM=parsetuple.TnM, 

479 TnS=parsetuple.TnS, 

480 ) 

481 

482 if isinstance(parsetuple, IntervalTuple): 

483 return cls.build_interval( 

484 start=parsetuple.start, end=parsetuple.end, duration=parsetuple.duration 

485 ) 

486 

487 if isinstance(parsetuple, RepeatingIntervalTuple): 

488 return cls.build_repeating_interval( 

489 R=parsetuple.R, Rnn=parsetuple.Rnn, interval=parsetuple.interval 

490 ) 

491 

492 return cls.build_timezone( 

493 negative=parsetuple.negative, 

494 Z=parsetuple.Z, 

495 hh=parsetuple.hh, 

496 mm=parsetuple.mm, 

497 name=parsetuple.name, 

498 ) 

499 

500 @classmethod 

501 def _is_interval_end_concise(cls, endtuple): 

502 if isinstance(endtuple, TimeTuple): 

503 return True 

504 

505 if isinstance(endtuple, DatetimeTuple): 

506 enddatetuple = endtuple.date 

507 else: 

508 enddatetuple = endtuple 

509 

510 if enddatetuple.YYYY is None: 

511 return True 

512 

513 return False 

514 

515 @classmethod 

516 def _combine_concise_interval_tuples(cls, starttuple, conciseendtuple): 

517 starttimetuple = None 

518 startdatetuple = None 

519 

520 endtimetuple = None 

521 enddatetuple = None 

522 

523 if isinstance(starttuple, DateTuple): 

524 startdatetuple = starttuple 

525 else: 

526 # Start is a datetime 

527 starttimetuple = starttuple.time 

528 startdatetuple = starttuple.date 

529 

530 if isinstance(conciseendtuple, DateTuple): 

531 enddatetuple = conciseendtuple 

532 elif isinstance(conciseendtuple, DatetimeTuple): 

533 enddatetuple = conciseendtuple.date 

534 endtimetuple = conciseendtuple.time 

535 else: 

536 # Time 

537 endtimetuple = conciseendtuple 

538 

539 if enddatetuple is not None: 

540 if enddatetuple.YYYY is None and enddatetuple.MM is None: 

541 newenddatetuple = DateTuple( 

542 YYYY=startdatetuple.YYYY, 

543 MM=startdatetuple.MM, 

544 DD=enddatetuple.DD, 

545 Www=enddatetuple.Www, 

546 D=enddatetuple.D, 

547 DDD=enddatetuple.DDD, 

548 ) 

549 else: 

550 newenddatetuple = DateTuple( 

551 YYYY=startdatetuple.YYYY, 

552 MM=enddatetuple.MM, 

553 DD=enddatetuple.DD, 

554 Www=enddatetuple.Www, 

555 D=enddatetuple.D, 

556 DDD=enddatetuple.DDD, 

557 ) 

558 

559 if endtimetuple is None: 

560 return newenddatetuple 

561 

562 if (starttimetuple is not None and starttimetuple.tz is not None) and ( 

563 endtimetuple is not None and endtimetuple.tz != starttimetuple.tz 

564 ): 

565 # Copy the timezone across 

566 endtimetuple = TimeTuple( 

567 hh=endtimetuple.hh, 

568 mm=endtimetuple.mm, 

569 ss=endtimetuple.ss, 

570 tz=starttimetuple.tz, 

571 ) 

572 

573 if enddatetuple is not None and endtimetuple is not None: 

574 return TupleBuilder.build_datetime(newenddatetuple, endtimetuple) 

575 

576 return TupleBuilder.build_datetime(startdatetuple, endtimetuple) 

577 

578 

579class TupleBuilder(BaseTimeBuilder): 

580 # Builder used to return the arguments as a tuple, cleans up some parse methods 

581 @classmethod 

582 def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None): 

583 

584 return DateTuple(YYYY, MM, DD, Www, D, DDD) 

585 

586 @classmethod 

587 def build_time(cls, hh=None, mm=None, ss=None, tz=None): 

588 return TimeTuple(hh, mm, ss, tz) 

589 

590 @classmethod 

591 def build_datetime(cls, date, time): 

592 return DatetimeTuple(date, time) 

593 

594 @classmethod 

595 def build_duration( 

596 cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None 

597 ): 

598 

599 return DurationTuple(PnY, PnM, PnW, PnD, TnH, TnM, TnS) 

600 

601 @classmethod 

602 def build_interval(cls, start=None, end=None, duration=None): 

603 return IntervalTuple(start, end, duration) 

604 

605 @classmethod 

606 def build_repeating_interval(cls, R=None, Rnn=None, interval=None): 

607 return RepeatingIntervalTuple(R, Rnn, interval) 

608 

609 @classmethod 

610 def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""): 

611 return TimezoneTuple(negative, Z, hh, mm, name)