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)