1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2025, Brandon Nielsen
4# All rights reserved.
5#
6# This software may be modified and distributed under the terms
7# of the BSD license. See the LICENSE file for details.
8
9import calendar
10from collections import namedtuple
11
12from aniso8601.exceptions import (
13 DayOutOfBoundsError,
14 HoursOutOfBoundsError,
15 ISOFormatError,
16 LeapSecondError,
17 MidnightBoundsError,
18 MinutesOutOfBoundsError,
19 MonthOutOfBoundsError,
20 SecondsOutOfBoundsError,
21 WeekOutOfBoundsError,
22 YearOutOfBoundsError,
23)
24
25DateTuple = namedtuple("Date", ["YYYY", "MM", "DD", "Www", "D", "DDD"])
26TimeTuple = namedtuple("Time", ["hh", "mm", "ss", "tz"])
27DatetimeTuple = namedtuple("Datetime", ["date", "time"])
28DurationTuple = namedtuple(
29 "Duration", ["PnY", "PnM", "PnW", "PnD", "TnH", "TnM", "TnS"]
30)
31IntervalTuple = namedtuple("Interval", ["start", "end", "duration"])
32RepeatingIntervalTuple = namedtuple("RepeatingInterval", ["R", "Rnn", "interval"])
33TimezoneTuple = namedtuple("Timezone", ["negative", "Z", "hh", "mm", "name"])
34
35Limit = namedtuple(
36 "Limit",
37 [
38 "casterrorstring",
39 "min",
40 "max",
41 "rangeexception",
42 "rangeerrorstring",
43 "rangefunc",
44 ],
45)
46
47
48def cast(
49 value,
50 castfunction,
51 caughtexceptions=(ValueError,),
52 thrownexception=ISOFormatError,
53 thrownmessage=None,
54):
55 try:
56 result = castfunction(value)
57 except caughtexceptions:
58 raise thrownexception(thrownmessage)
59
60 return result
61
62
63def range_check(valuestr, limit):
64 # Returns cast value if in range, raises defined exceptions on failure
65 if valuestr is None:
66 return None
67
68 if "." in valuestr:
69 castfunc = float
70 else:
71 castfunc = int
72
73 value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring)
74
75 if limit.min is not None and value < limit.min:
76 raise limit.rangeexception(limit.rangeerrorstring)
77
78 if limit.max is not None and value > limit.max:
79 raise limit.rangeexception(limit.rangeerrorstring)
80
81 return value
82
83
84class BaseTimeBuilder(object):
85 # Limit tuple format cast function, cast error string,
86 # lower limit, upper limit, limit error string
87 DATE_YYYY_LIMIT = Limit(
88 "Invalid year string.",
89 0000,
90 9999,
91 YearOutOfBoundsError,
92 "Year must be between 1..9999.",
93 range_check,
94 )
95 DATE_MM_LIMIT = Limit(
96 "Invalid month string.",
97 1,
98 12,
99 MonthOutOfBoundsError,
100 "Month must be between 1..12.",
101 range_check,
102 )
103 DATE_DD_LIMIT = Limit(
104 "Invalid day string.",
105 1,
106 31,
107 DayOutOfBoundsError,
108 "Day must be between 1..31.",
109 range_check,
110 )
111 DATE_WWW_LIMIT = Limit(
112 "Invalid week string.",
113 1,
114 53,
115 WeekOutOfBoundsError,
116 "Week number must be between 1..53.",
117 range_check,
118 )
119 DATE_D_LIMIT = Limit(
120 "Invalid weekday string.",
121 1,
122 7,
123 DayOutOfBoundsError,
124 "Weekday number must be between 1..7.",
125 range_check,
126 )
127 DATE_DDD_LIMIT = Limit(
128 "Invalid ordinal day string.",
129 1,
130 366,
131 DayOutOfBoundsError,
132 "Ordinal day must be between 1..366.",
133 range_check,
134 )
135 TIME_HH_LIMIT = Limit(
136 "Invalid hour string.",
137 0,
138 24,
139 HoursOutOfBoundsError,
140 "Hour must be between 0..24 with 24 representing midnight.",
141 range_check,
142 )
143 TIME_MM_LIMIT = Limit(
144 "Invalid minute string.",
145 0,
146 59,
147 MinutesOutOfBoundsError,
148 "Minute must be between 0..59.",
149 range_check,
150 )
151 TIME_SS_LIMIT = Limit(
152 "Invalid second string.",
153 0,
154 60,
155 SecondsOutOfBoundsError,
156 "Second must be between 0..60 with 60 representing a leap second.",
157 range_check,
158 )
159 TZ_HH_LIMIT = Limit(
160 "Invalid timezone hour string.",
161 0,
162 23,
163 HoursOutOfBoundsError,
164 "Hour must be between 0..23.",
165 range_check,
166 )
167 TZ_MM_LIMIT = Limit(
168 "Invalid timezone minute string.",
169 0,
170 59,
171 MinutesOutOfBoundsError,
172 "Minute must be between 0..59.",
173 range_check,
174 )
175 DURATION_PNY_LIMIT = Limit(
176 "Invalid year duration string.",
177 0,
178 None,
179 ISOFormatError,
180 "Duration years component must be positive.",
181 range_check,
182 )
183 DURATION_PNM_LIMIT = Limit(
184 "Invalid month duration string.",
185 0,
186 None,
187 ISOFormatError,
188 "Duration months component must be positive.",
189 range_check,
190 )
191 DURATION_PNW_LIMIT = Limit(
192 "Invalid week duration string.",
193 0,
194 None,
195 ISOFormatError,
196 "Duration weeks component must be positive.",
197 range_check,
198 )
199 DURATION_PND_LIMIT = Limit(
200 "Invalid day duration string.",
201 0,
202 None,
203 ISOFormatError,
204 "Duration days component must be positive.",
205 range_check,
206 )
207 DURATION_TNH_LIMIT = Limit(
208 "Invalid hour duration string.",
209 0,
210 None,
211 ISOFormatError,
212 "Duration hours component must be positive.",
213 range_check,
214 )
215 DURATION_TNM_LIMIT = Limit(
216 "Invalid minute duration string.",
217 0,
218 None,
219 ISOFormatError,
220 "Duration minutes component must be positive.",
221 range_check,
222 )
223 DURATION_TNS_LIMIT = Limit(
224 "Invalid second duration string.",
225 0,
226 None,
227 ISOFormatError,
228 "Duration seconds component must be positive.",
229 range_check,
230 )
231 INTERVAL_RNN_LIMIT = Limit(
232 "Invalid duration repetition string.",
233 0,
234 None,
235 ISOFormatError,
236 "Duration repetition count must be positive.",
237 range_check,
238 )
239
240 DATE_RANGE_DICT = {
241 "YYYY": DATE_YYYY_LIMIT,
242 "MM": DATE_MM_LIMIT,
243 "DD": DATE_DD_LIMIT,
244 "Www": DATE_WWW_LIMIT,
245 "D": DATE_D_LIMIT,
246 "DDD": DATE_DDD_LIMIT,
247 }
248
249 TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT}
250
251 DURATION_RANGE_DICT = {
252 "PnY": DURATION_PNY_LIMIT,
253 "PnM": DURATION_PNM_LIMIT,
254 "PnW": DURATION_PNW_LIMIT,
255 "PnD": DURATION_PND_LIMIT,
256 "TnH": DURATION_TNH_LIMIT,
257 "TnM": DURATION_TNM_LIMIT,
258 "TnS": DURATION_TNS_LIMIT,
259 }
260
261 REPEATING_INTERVAL_RANGE_DICT = {"Rnn": INTERVAL_RNN_LIMIT}
262
263 TIMEZONE_RANGE_DICT = {"hh": TZ_HH_LIMIT, "mm": TZ_MM_LIMIT}
264
265 LEAP_SECONDS_SUPPORTED = False
266
267 @classmethod
268 def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None):
269 raise NotImplementedError
270
271 @classmethod
272 def build_time(cls, hh=None, mm=None, ss=None, tz=None):
273 raise NotImplementedError
274
275 @classmethod
276 def build_datetime(cls, date, time):
277 raise NotImplementedError
278
279 @classmethod
280 def build_duration(
281 cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None
282 ):
283 raise NotImplementedError
284
285 @classmethod
286 def build_interval(cls, start=None, end=None, duration=None):
287 # start, end, and duration are all tuples
288 raise NotImplementedError
289
290 @classmethod
291 def build_repeating_interval(cls, R=None, Rnn=None, interval=None):
292 # interval is a tuple
293 raise NotImplementedError
294
295 @classmethod
296 def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""):
297 raise NotImplementedError
298
299 @classmethod
300 def range_check_date(
301 cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None, rangedict=None
302 ):
303 if rangedict is None:
304 rangedict = cls.DATE_RANGE_DICT
305
306 if "YYYY" in rangedict:
307 YYYY = rangedict["YYYY"].rangefunc(YYYY, rangedict["YYYY"])
308
309 if "MM" in rangedict:
310 MM = rangedict["MM"].rangefunc(MM, rangedict["MM"])
311
312 if "DD" in rangedict:
313 DD = rangedict["DD"].rangefunc(DD, rangedict["DD"])
314
315 if "Www" in rangedict:
316 Www = rangedict["Www"].rangefunc(Www, rangedict["Www"])
317
318 if "D" in rangedict:
319 D = rangedict["D"].rangefunc(D, rangedict["D"])
320
321 if "DDD" in rangedict:
322 DDD = rangedict["DDD"].rangefunc(DDD, rangedict["DDD"])
323
324 if DD is not None:
325 # Check calendar
326 if DD > calendar.monthrange(YYYY, MM)[1]:
327 raise DayOutOfBoundsError(
328 "{0} is out of range for {1}-{2}".format(DD, YYYY, MM)
329 )
330
331 if DDD is not None:
332 if calendar.isleap(YYYY) is False and DDD == 366:
333 raise DayOutOfBoundsError(
334 "{0} is only valid for leap year.".format(DDD)
335 )
336
337 return (YYYY, MM, DD, Www, D, DDD)
338
339 @classmethod
340 def range_check_time(cls, hh=None, mm=None, ss=None, tz=None, rangedict=None):
341 # Used for midnight and leap second handling
342 midnight = False # Handle hh = '24' specially
343
344 if rangedict is None:
345 rangedict = cls.TIME_RANGE_DICT
346
347 if "hh" in rangedict:
348 try:
349 hh = rangedict["hh"].rangefunc(hh, rangedict["hh"])
350 except HoursOutOfBoundsError as e:
351 if float(hh) > 24 and float(hh) < 25:
352 raise MidnightBoundsError("Hour 24 may only represent midnight.")
353
354 raise e
355
356 if "mm" in rangedict:
357 mm = rangedict["mm"].rangefunc(mm, rangedict["mm"])
358
359 if "ss" in rangedict:
360 ss = rangedict["ss"].rangefunc(ss, rangedict["ss"])
361
362 if hh is not None and hh == 24:
363 midnight = True
364
365 # Handle midnight range
366 if midnight is True and (
367 (mm is not None and mm != 0) or (ss is not None and ss != 0)
368 ):
369 raise MidnightBoundsError("Hour 24 may only represent midnight.")
370
371 if cls.LEAP_SECONDS_SUPPORTED is True:
372 if hh != 23 and mm != 59 and ss == 60:
373 raise cls.TIME_SS_LIMIT.rangeexception(
374 cls.TIME_SS_LIMIT.rangeerrorstring
375 )
376 else:
377 if hh == 23 and mm == 59 and ss == 60:
378 # https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is
379 raise LeapSecondError("Leap seconds are not supported.")
380
381 if ss == 60:
382 raise cls.TIME_SS_LIMIT.rangeexception(
383 cls.TIME_SS_LIMIT.rangeerrorstring
384 )
385
386 return (hh, mm, ss, tz)
387
388 @classmethod
389 def range_check_duration(
390 cls,
391 PnY=None,
392 PnM=None,
393 PnW=None,
394 PnD=None,
395 TnH=None,
396 TnM=None,
397 TnS=None,
398 rangedict=None,
399 ):
400 if rangedict is None:
401 rangedict = cls.DURATION_RANGE_DICT
402
403 if "PnY" in rangedict:
404 PnY = rangedict["PnY"].rangefunc(PnY, rangedict["PnY"])
405
406 if "PnM" in rangedict:
407 PnM = rangedict["PnM"].rangefunc(PnM, rangedict["PnM"])
408
409 if "PnW" in rangedict:
410 PnW = rangedict["PnW"].rangefunc(PnW, rangedict["PnW"])
411
412 if "PnD" in rangedict:
413 PnD = rangedict["PnD"].rangefunc(PnD, rangedict["PnD"])
414
415 if "TnH" in rangedict:
416 TnH = rangedict["TnH"].rangefunc(TnH, rangedict["TnH"])
417
418 if "TnM" in rangedict:
419 TnM = rangedict["TnM"].rangefunc(TnM, rangedict["TnM"])
420
421 if "TnS" in rangedict:
422 TnS = rangedict["TnS"].rangefunc(TnS, rangedict["TnS"])
423
424 return (PnY, PnM, PnW, PnD, TnH, TnM, TnS)
425
426 @classmethod
427 def range_check_repeating_interval(
428 cls, R=None, Rnn=None, interval=None, rangedict=None
429 ):
430 if rangedict is None:
431 rangedict = cls.REPEATING_INTERVAL_RANGE_DICT
432
433 if "Rnn" in rangedict:
434 Rnn = rangedict["Rnn"].rangefunc(Rnn, rangedict["Rnn"])
435
436 return (R, Rnn, interval)
437
438 @classmethod
439 def range_check_timezone(
440 cls, negative=None, Z=None, hh=None, mm=None, name="", rangedict=None
441 ):
442 if rangedict is None:
443 rangedict = cls.TIMEZONE_RANGE_DICT
444
445 if "hh" in rangedict:
446 hh = rangedict["hh"].rangefunc(hh, rangedict["hh"])
447
448 if "mm" in rangedict:
449 mm = rangedict["mm"].rangefunc(mm, rangedict["mm"])
450
451 return (negative, Z, hh, mm, name)
452
453 @classmethod
454 def _build_object(cls, parsetuple):
455 # Given a TupleBuilder tuple, build the correct object
456 if isinstance(parsetuple, DateTuple):
457 return cls.build_date(
458 YYYY=parsetuple.YYYY,
459 MM=parsetuple.MM,
460 DD=parsetuple.DD,
461 Www=parsetuple.Www,
462 D=parsetuple.D,
463 DDD=parsetuple.DDD,
464 )
465
466 if isinstance(parsetuple, TimeTuple):
467 return cls.build_time(
468 hh=parsetuple.hh, mm=parsetuple.mm, ss=parsetuple.ss, tz=parsetuple.tz
469 )
470
471 if isinstance(parsetuple, DatetimeTuple):
472 return cls.build_datetime(parsetuple.date, parsetuple.time)
473
474 if isinstance(parsetuple, DurationTuple):
475 return cls.build_duration(
476 PnY=parsetuple.PnY,
477 PnM=parsetuple.PnM,
478 PnW=parsetuple.PnW,
479 PnD=parsetuple.PnD,
480 TnH=parsetuple.TnH,
481 TnM=parsetuple.TnM,
482 TnS=parsetuple.TnS,
483 )
484
485 if isinstance(parsetuple, IntervalTuple):
486 return cls.build_interval(
487 start=parsetuple.start, end=parsetuple.end, duration=parsetuple.duration
488 )
489
490 if isinstance(parsetuple, RepeatingIntervalTuple):
491 return cls.build_repeating_interval(
492 R=parsetuple.R, Rnn=parsetuple.Rnn, interval=parsetuple.interval
493 )
494
495 return cls.build_timezone(
496 negative=parsetuple.negative,
497 Z=parsetuple.Z,
498 hh=parsetuple.hh,
499 mm=parsetuple.mm,
500 name=parsetuple.name,
501 )
502
503 @classmethod
504 def _is_interval_end_concise(cls, endtuple):
505 if isinstance(endtuple, TimeTuple):
506 return True
507
508 if isinstance(endtuple, DatetimeTuple):
509 enddatetuple = endtuple.date
510 else:
511 enddatetuple = endtuple
512
513 if enddatetuple.YYYY is None:
514 return True
515
516 return False
517
518 @classmethod
519 def _combine_concise_interval_tuples(cls, starttuple, conciseendtuple):
520 starttimetuple = None
521 startdatetuple = None
522
523 endtimetuple = None
524 enddatetuple = None
525
526 if isinstance(starttuple, DateTuple):
527 startdatetuple = starttuple
528 else:
529 # Start is a datetime
530 starttimetuple = starttuple.time
531 startdatetuple = starttuple.date
532
533 if isinstance(conciseendtuple, DateTuple):
534 enddatetuple = conciseendtuple
535 elif isinstance(conciseendtuple, DatetimeTuple):
536 enddatetuple = conciseendtuple.date
537 endtimetuple = conciseendtuple.time
538 else:
539 # Time
540 endtimetuple = conciseendtuple
541
542 if enddatetuple is not None:
543 if enddatetuple.YYYY is None and enddatetuple.MM is None:
544 newenddatetuple = DateTuple(
545 YYYY=startdatetuple.YYYY,
546 MM=startdatetuple.MM,
547 DD=enddatetuple.DD,
548 Www=enddatetuple.Www,
549 D=enddatetuple.D,
550 DDD=enddatetuple.DDD,
551 )
552 else:
553 newenddatetuple = DateTuple(
554 YYYY=startdatetuple.YYYY,
555 MM=enddatetuple.MM,
556 DD=enddatetuple.DD,
557 Www=enddatetuple.Www,
558 D=enddatetuple.D,
559 DDD=enddatetuple.DDD,
560 )
561
562 if endtimetuple is None:
563 return newenddatetuple
564
565 if (starttimetuple is not None and starttimetuple.tz is not None) and (
566 endtimetuple is not None and endtimetuple.tz != starttimetuple.tz
567 ):
568 # Copy the timezone across
569 endtimetuple = TimeTuple(
570 hh=endtimetuple.hh,
571 mm=endtimetuple.mm,
572 ss=endtimetuple.ss,
573 tz=starttimetuple.tz,
574 )
575
576 if enddatetuple is not None and endtimetuple is not None:
577 return TupleBuilder.build_datetime(newenddatetuple, endtimetuple)
578
579 return TupleBuilder.build_datetime(startdatetuple, endtimetuple)
580
581
582class TupleBuilder(BaseTimeBuilder):
583 # Builder used to return the arguments as a tuple, cleans up some parse methods
584 @classmethod
585 def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None):
586
587 return DateTuple(YYYY, MM, DD, Www, D, DDD)
588
589 @classmethod
590 def build_time(cls, hh=None, mm=None, ss=None, tz=None):
591 return TimeTuple(hh, mm, ss, tz)
592
593 @classmethod
594 def build_datetime(cls, date, time):
595 return DatetimeTuple(date, time)
596
597 @classmethod
598 def build_duration(
599 cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None
600 ):
601
602 return DurationTuple(PnY, PnM, PnW, PnD, TnH, TnM, TnS)
603
604 @classmethod
605 def build_interval(cls, start=None, end=None, duration=None):
606 return IntervalTuple(start, end, duration)
607
608 @classmethod
609 def build_repeating_interval(cls, R=None, Rnn=None, interval=None):
610 return RepeatingIntervalTuple(R, Rnn, interval)
611
612 @classmethod
613 def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""):
614 return TimezoneTuple(negative, Z, hh, mm, name)