1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2026, Brandon Nielsen
4# SPDX-License-Identifier: BSD-3-Clause
5
6import datetime
7from collections import namedtuple
8from functools import partial
9
10from aniso8601.builders import (
11 BaseTimeBuilder,
12 DateTuple,
13 Limit,
14 TupleBuilder,
15 cast,
16 range_check,
17)
18from aniso8601.exceptions import (
19 DayOutOfBoundsError,
20 HoursOutOfBoundsError,
21 MinutesOutOfBoundsError,
22 MonthOutOfBoundsError,
23 SecondsOutOfBoundsError,
24 WeekOutOfBoundsError,
25 YearOutOfBoundsError,
26)
27from aniso8601.utcoffset import UTCOffset
28
29DAYS_PER_YEAR = 365
30DAYS_PER_MONTH = 30
31DAYS_PER_WEEK = 7
32
33HOURS_PER_DAY = 24
34
35MINUTES_PER_HOUR = 60
36MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY
37
38SECONDS_PER_MINUTE = 60
39SECONDS_PER_DAY = MINUTES_PER_DAY * SECONDS_PER_MINUTE
40
41MICROSECONDS_PER_SECOND = int(1e6)
42
43MICROSECONDS_PER_MINUTE = 60 * MICROSECONDS_PER_SECOND
44MICROSECONDS_PER_HOUR = 60 * MICROSECONDS_PER_MINUTE
45MICROSECONDS_PER_DAY = 24 * MICROSECONDS_PER_HOUR
46MICROSECONDS_PER_WEEK = 7 * MICROSECONDS_PER_DAY
47MICROSECONDS_PER_MONTH = DAYS_PER_MONTH * MICROSECONDS_PER_DAY
48MICROSECONDS_PER_YEAR = DAYS_PER_YEAR * MICROSECONDS_PER_DAY
49
50TIMEDELTA_MAX_DAYS = datetime.timedelta.max.days
51
52FractionalComponent = namedtuple(
53 "FractionalComponent", ["principal", "microsecondremainder"]
54)
55
56
57def year_range_check(valuestr, limit):
58 YYYYstr = valuestr
59
60 # Truncated dates, like '19', refer to 1900-1999 inclusive,
61 # we simply parse to 1900, Y and YYY strings are not supported
62 if len(valuestr) == 2:
63 # Shift 0s in from the left to form complete year
64 YYYYstr = valuestr.ljust(4, "0")
65
66 return range_check(YYYYstr, limit)
67
68
69def fractional_range_check(conversion, valuestr, limit):
70 if valuestr is None:
71 return None
72
73 if "." in valuestr:
74 castfunc = partial(_cast_to_fractional_component, conversion)
75 else:
76 castfunc = int
77
78 value = cast(valuestr, castfunc, thrownmessage=limit.casterrorstring)
79
80 if isinstance(value, FractionalComponent):
81 tocheck = float(valuestr)
82 else:
83 tocheck = int(valuestr)
84
85 if limit.min is not None and tocheck < limit.min:
86 raise limit.rangeexception(limit.rangeerrorstring)
87
88 if limit.max is not None and tocheck > limit.max:
89 raise limit.rangeexception(limit.rangeerrorstring)
90
91 return value
92
93
94def _cast_to_fractional_component(conversion, floatstr):
95 # Splits a string with a decimal point into an int, and
96 # int representing the floating point remainder as a number
97 # of microseconds, determined by multiplying by conversion
98 intpart, floatpart = floatstr.split(".")
99
100 intvalue = int(intpart)
101 preconvertedvalue = int(floatpart)
102
103 convertedvalue = (preconvertedvalue * conversion) // (10 ** len(floatpart))
104
105 return FractionalComponent(intvalue, convertedvalue)
106
107
108class PythonTimeBuilder(BaseTimeBuilder):
109 # 0000 (1 BC) is not representable as a Python date
110 DATE_YYYY_LIMIT = Limit(
111 "Invalid year string.",
112 datetime.MINYEAR,
113 datetime.MAXYEAR,
114 YearOutOfBoundsError,
115 "Year must be between {0}..{1}.".format(datetime.MINYEAR, datetime.MAXYEAR),
116 year_range_check,
117 )
118 TIME_HH_LIMIT = Limit(
119 "Invalid hour string.",
120 0,
121 24,
122 HoursOutOfBoundsError,
123 "Hour must be between 0..24 with 24 representing midnight.",
124 partial(fractional_range_check, MICROSECONDS_PER_HOUR),
125 )
126 TIME_MM_LIMIT = Limit(
127 "Invalid minute string.",
128 0,
129 59,
130 MinutesOutOfBoundsError,
131 "Minute must be between 0..59.",
132 partial(fractional_range_check, MICROSECONDS_PER_MINUTE),
133 )
134 TIME_SS_LIMIT = Limit(
135 "Invalid second string.",
136 0,
137 60,
138 SecondsOutOfBoundsError,
139 "Second must be between 0..60 with 60 representing a leap second.",
140 partial(fractional_range_check, MICROSECONDS_PER_SECOND),
141 )
142 DURATION_PNY_LIMIT = Limit(
143 "Invalid year duration string.",
144 None,
145 None,
146 YearOutOfBoundsError,
147 None,
148 partial(fractional_range_check, MICROSECONDS_PER_YEAR),
149 )
150 DURATION_PNM_LIMIT = Limit(
151 "Invalid month duration string.",
152 None,
153 None,
154 MonthOutOfBoundsError,
155 None,
156 partial(fractional_range_check, MICROSECONDS_PER_MONTH),
157 )
158 DURATION_PNW_LIMIT = Limit(
159 "Invalid week duration string.",
160 None,
161 None,
162 WeekOutOfBoundsError,
163 None,
164 partial(fractional_range_check, MICROSECONDS_PER_WEEK),
165 )
166 DURATION_PND_LIMIT = Limit(
167 "Invalid day duration string.",
168 None,
169 None,
170 DayOutOfBoundsError,
171 None,
172 partial(fractional_range_check, MICROSECONDS_PER_DAY),
173 )
174 DURATION_TNH_LIMIT = Limit(
175 "Invalid hour duration string.",
176 None,
177 None,
178 HoursOutOfBoundsError,
179 None,
180 partial(fractional_range_check, MICROSECONDS_PER_HOUR),
181 )
182 DURATION_TNM_LIMIT = Limit(
183 "Invalid minute duration string.",
184 None,
185 None,
186 MinutesOutOfBoundsError,
187 None,
188 partial(fractional_range_check, MICROSECONDS_PER_MINUTE),
189 )
190 DURATION_TNS_LIMIT = Limit(
191 "Invalid second duration string.",
192 None,
193 None,
194 SecondsOutOfBoundsError,
195 None,
196 partial(fractional_range_check, MICROSECONDS_PER_SECOND),
197 )
198
199 DATE_RANGE_DICT = BaseTimeBuilder.DATE_RANGE_DICT
200 DATE_RANGE_DICT["YYYY"] = DATE_YYYY_LIMIT
201
202 TIME_RANGE_DICT = {"hh": TIME_HH_LIMIT, "mm": TIME_MM_LIMIT, "ss": TIME_SS_LIMIT}
203
204 DURATION_RANGE_DICT = {
205 "PnY": DURATION_PNY_LIMIT,
206 "PnM": DURATION_PNM_LIMIT,
207 "PnW": DURATION_PNW_LIMIT,
208 "PnD": DURATION_PND_LIMIT,
209 "TnH": DURATION_TNH_LIMIT,
210 "TnM": DURATION_TNM_LIMIT,
211 "TnS": DURATION_TNS_LIMIT,
212 }
213
214 @classmethod
215 def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None, DDD=None):
216 YYYY, MM, DD, Www, D, DDD = cls.range_check_date(YYYY, MM, DD, Www, D, DDD)
217
218 if MM is None:
219 MM = 1
220
221 if DD is None:
222 DD = 1
223
224 if DDD is not None:
225 return PythonTimeBuilder._build_ordinal_date(YYYY, DDD)
226
227 if Www is not None:
228 return PythonTimeBuilder._build_week_date(YYYY, Www, isoday=D)
229
230 return datetime.date(YYYY, MM, DD)
231
232 @classmethod
233 def build_time(cls, hh=None, mm=None, ss=None, tz=None):
234 # Builds a time from the given parts, handling fractional arguments
235 # where necessary
236 hours = 0
237 minutes = 0
238 seconds = 0
239 microseconds = 0
240
241 hh, mm, ss, tz = cls.range_check_time(hh, mm, ss, tz)
242
243 if isinstance(hh, FractionalComponent):
244 hours = hh.principal
245 microseconds = hh.microsecondremainder
246 elif hh is not None:
247 hours = hh
248
249 if isinstance(mm, FractionalComponent):
250 minutes = mm.principal
251 microseconds = mm.microsecondremainder
252 elif mm is not None:
253 minutes = mm
254
255 if isinstance(ss, FractionalComponent):
256 seconds = ss.principal
257 microseconds = ss.microsecondremainder
258 elif ss is not None:
259 seconds = ss
260
261 (
262 hours,
263 minutes,
264 seconds,
265 microseconds,
266 ) = PythonTimeBuilder._distribute_microseconds(
267 microseconds,
268 (hours, minutes, seconds),
269 (MICROSECONDS_PER_HOUR, MICROSECONDS_PER_MINUTE, MICROSECONDS_PER_SECOND),
270 )
271
272 # Move midnight into range
273 if hours == 24:
274 hours = 0
275
276 # Datetimes don't handle fractional components, so we use a timedelta
277 if tz is not None:
278 return (
279 datetime.datetime(
280 1, 1, 1, hour=hours, minute=minutes, tzinfo=cls._build_object(tz)
281 )
282 + datetime.timedelta(seconds=seconds, microseconds=microseconds)
283 ).timetz()
284
285 return (
286 datetime.datetime(1, 1, 1, hour=hours, minute=minutes)
287 + datetime.timedelta(seconds=seconds, microseconds=microseconds)
288 ).time()
289
290 @classmethod
291 def build_datetime(cls, date, time):
292 return datetime.datetime.combine(
293 cls._build_object(date), cls._build_object(time)
294 )
295
296 @classmethod
297 def build_duration(
298 cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None, TnM=None, TnS=None
299 ):
300 # PnY and PnM will be distributed to PnD, microsecond remainder to TnS
301 PnY, PnM, PnW, PnD, TnH, TnM, TnS = cls.range_check_duration(
302 PnY, PnM, PnW, PnD, TnH, TnM, TnS
303 )
304
305 seconds = TnS.principal
306 microseconds = TnS.microsecondremainder
307
308 return datetime.timedelta(
309 days=PnD,
310 seconds=seconds,
311 microseconds=microseconds,
312 minutes=TnM,
313 hours=TnH,
314 weeks=PnW,
315 )
316
317 @classmethod
318 def build_interval(cls, start=None, end=None, duration=None):
319 start, end, duration = cls.range_check_interval(start, end, duration)
320
321 if start is not None and end is not None:
322 # <start>/<end>
323 startobject = cls._build_object(start)
324 endobject = cls._build_object(end)
325
326 return (startobject, endobject)
327
328 durationobject = cls._build_object(duration)
329
330 # Determine if datetime promotion is required
331 datetimerequired = (
332 duration.TnH is not None
333 or duration.TnM is not None
334 or duration.TnS is not None
335 or durationobject.seconds != 0
336 or durationobject.microseconds != 0
337 )
338
339 if end is not None:
340 # <duration>/<end>
341 endobject = cls._build_object(end)
342
343 # Range check
344 if isinstance(end, DateTuple) and datetimerequired is True:
345 # <end> is a date, and <duration> requires datetime resolution
346 return (
347 endobject,
348 cls.build_datetime(end, TupleBuilder.build_time()) - durationobject,
349 )
350
351 return (endobject, endobject - durationobject)
352
353 # <start>/<duration>
354 startobject = cls._build_object(start)
355
356 # Range check
357 if isinstance(start, DateTuple) and datetimerequired is True:
358 # <start> is a date, and <duration> requires datetime resolution
359 return (
360 startobject,
361 cls.build_datetime(start, TupleBuilder.build_time()) + durationobject,
362 )
363
364 return (startobject, startobject + durationobject)
365
366 @classmethod
367 def build_repeating_interval(cls, R=None, Rnn=None, interval=None):
368 startobject = None
369 endobject = None
370
371 R, Rnn, interval = cls.range_check_repeating_interval(R, Rnn, interval)
372
373 if interval.start is not None:
374 startobject = cls._build_object(interval.start)
375
376 if interval.end is not None:
377 endobject = cls._build_object(interval.end)
378
379 if interval.duration is not None:
380 durationobject = cls._build_object(interval.duration)
381 else:
382 durationobject = endobject - startobject
383
384 if R is True:
385 if startobject is not None:
386 return cls._date_generator_unbounded(startobject, durationobject)
387
388 return cls._date_generator_unbounded(endobject, -durationobject)
389
390 iterations = int(Rnn)
391
392 if startobject is not None:
393 return cls._date_generator(startobject, durationobject, iterations)
394
395 return cls._date_generator(endobject, -durationobject, iterations)
396
397 @classmethod
398 def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=""):
399 negative, Z, hh, mm, name = cls.range_check_timezone(negative, Z, hh, mm, name)
400
401 if Z is True:
402 # Z -> UTC
403 return UTCOffset(name="UTC", minutes=0)
404
405 tzhour = int(hh)
406
407 if mm is not None:
408 tzminute = int(mm)
409 else:
410 tzminute = 0
411
412 if negative is True:
413 return UTCOffset(name=name, minutes=-(tzhour * 60 + tzminute))
414
415 return UTCOffset(name=name, minutes=tzhour * 60 + tzminute)
416
417 @classmethod
418 def range_check_duration(
419 cls,
420 PnY=None,
421 PnM=None,
422 PnW=None,
423 PnD=None,
424 TnH=None,
425 TnM=None,
426 TnS=None,
427 rangedict=None,
428 ):
429 years = 0
430 months = 0
431 days = 0
432 weeks = 0
433 hours = 0
434 minutes = 0
435 seconds = 0
436 microseconds = 0
437
438 PnY, PnM, PnW, PnD, TnH, TnM, TnS = BaseTimeBuilder.range_check_duration(
439 PnY, PnM, PnW, PnD, TnH, TnM, TnS, rangedict=cls.DURATION_RANGE_DICT
440 )
441
442 if PnY is not None:
443 if isinstance(PnY, FractionalComponent):
444 years = PnY.principal
445 microseconds = PnY.microsecondremainder
446 else:
447 years = PnY
448
449 if years * DAYS_PER_YEAR > TIMEDELTA_MAX_DAYS:
450 raise YearOutOfBoundsError("Duration exceeds maximum timedelta size.")
451
452 if PnM is not None:
453 if isinstance(PnM, FractionalComponent):
454 months = PnM.principal
455 microseconds = PnM.microsecondremainder
456 else:
457 months = PnM
458
459 if months * DAYS_PER_MONTH > TIMEDELTA_MAX_DAYS:
460 raise MonthOutOfBoundsError("Duration exceeds maximum timedelta size.")
461
462 if PnW is not None:
463 if isinstance(PnW, FractionalComponent):
464 weeks = PnW.principal
465 microseconds = PnW.microsecondremainder
466 else:
467 weeks = PnW
468
469 if weeks * DAYS_PER_WEEK > TIMEDELTA_MAX_DAYS:
470 raise WeekOutOfBoundsError("Duration exceeds maximum timedelta size.")
471
472 if PnD is not None:
473 if isinstance(PnD, FractionalComponent):
474 days = PnD.principal
475 microseconds = PnD.microsecondremainder
476 else:
477 days = PnD
478
479 if days > TIMEDELTA_MAX_DAYS:
480 raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.")
481
482 if TnH is not None:
483 if isinstance(TnH, FractionalComponent):
484 hours = TnH.principal
485 microseconds = TnH.microsecondremainder
486 else:
487 hours = TnH
488
489 if hours // HOURS_PER_DAY > TIMEDELTA_MAX_DAYS:
490 raise HoursOutOfBoundsError("Duration exceeds maximum timedelta size.")
491
492 if TnM is not None:
493 if isinstance(TnM, FractionalComponent):
494 minutes = TnM.principal
495 microseconds = TnM.microsecondremainder
496 else:
497 minutes = TnM
498
499 if minutes // MINUTES_PER_DAY > TIMEDELTA_MAX_DAYS:
500 raise MinutesOutOfBoundsError(
501 "Duration exceeds maximum timedelta size."
502 )
503
504 if TnS is not None:
505 if isinstance(TnS, FractionalComponent):
506 seconds = TnS.principal
507 microseconds = TnS.microsecondremainder
508 else:
509 seconds = TnS
510
511 if seconds // SECONDS_PER_DAY > TIMEDELTA_MAX_DAYS:
512 raise SecondsOutOfBoundsError(
513 "Duration exceeds maximum timedelta size."
514 )
515
516 (
517 years,
518 months,
519 weeks,
520 days,
521 hours,
522 minutes,
523 seconds,
524 microseconds,
525 ) = PythonTimeBuilder._distribute_microseconds(
526 microseconds,
527 (years, months, weeks, days, hours, minutes, seconds),
528 (
529 MICROSECONDS_PER_YEAR,
530 MICROSECONDS_PER_MONTH,
531 MICROSECONDS_PER_WEEK,
532 MICROSECONDS_PER_DAY,
533 MICROSECONDS_PER_HOUR,
534 MICROSECONDS_PER_MINUTE,
535 MICROSECONDS_PER_SECOND,
536 ),
537 )
538
539 # Note that weeks can be handled without conversion to days
540 totaldays = years * DAYS_PER_YEAR + months * DAYS_PER_MONTH + days
541
542 # Check against timedelta limits
543 if (
544 totaldays
545 + weeks * DAYS_PER_WEEK
546 + hours // HOURS_PER_DAY
547 + minutes // MINUTES_PER_DAY
548 + seconds // SECONDS_PER_DAY
549 > TIMEDELTA_MAX_DAYS
550 ):
551 raise DayOutOfBoundsError("Duration exceeds maximum timedelta size.")
552
553 return (
554 None,
555 None,
556 weeks,
557 totaldays,
558 hours,
559 minutes,
560 FractionalComponent(seconds, microseconds),
561 )
562
563 @classmethod
564 def range_check_interval(cls, start=None, end=None, duration=None):
565 # Handles concise format, range checks any potential durations
566 if start is not None and end is not None:
567 # <start>/<end>
568 # Handle concise format
569 if cls._is_interval_end_concise(end) is True:
570 end = cls._combine_concise_interval_tuples(start, end)
571
572 return (start, end, duration)
573
574 durationobject = cls._build_object(duration)
575
576 if end is not None:
577 # <duration>/<end>
578 endobject = cls._build_object(end)
579
580 # Range check
581 if isinstance(end, DateTuple):
582 enddatetime = cls.build_datetime(end, TupleBuilder.build_time())
583
584 if enddatetime - datetime.datetime.min < durationobject:
585 raise YearOutOfBoundsError("Interval end less than minimium date.")
586 else:
587 mindatetime = datetime.datetime.min
588
589 if end.time.tz is not None:
590 mindatetime = mindatetime.replace(tzinfo=endobject.tzinfo)
591
592 if endobject - mindatetime < durationobject:
593 raise YearOutOfBoundsError("Interval end less than minimium date.")
594 else:
595 # <start>/<duration>
596 startobject = cls._build_object(start)
597
598 # Range check
599 if type(start) is DateTuple:
600 startdatetime = cls.build_datetime(start, TupleBuilder.build_time())
601
602 if datetime.datetime.max - startdatetime < durationobject:
603 raise YearOutOfBoundsError(
604 "Interval end greater than maximum date."
605 )
606 else:
607 maxdatetime = datetime.datetime.max
608
609 if start.time.tz is not None:
610 maxdatetime = maxdatetime.replace(tzinfo=startobject.tzinfo)
611
612 if maxdatetime - startobject < durationobject:
613 raise YearOutOfBoundsError(
614 "Interval end greater than maximum date."
615 )
616
617 return (start, end, duration)
618
619 @staticmethod
620 def _build_week_date(isoyear, isoweek, isoday=None):
621 if isoday is None:
622 return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta(
623 weeks=isoweek - 1
624 )
625
626 return PythonTimeBuilder._iso_year_start(isoyear) + datetime.timedelta(
627 weeks=isoweek - 1, days=isoday - 1
628 )
629
630 @staticmethod
631 def _build_ordinal_date(isoyear, isoday):
632 # Day of year to a date
633 # https://stackoverflow.com/questions/2427555/python-question-year-and-day-of-year-to-date
634 builtdate = datetime.date(isoyear, 1, 1) + datetime.timedelta(days=isoday - 1)
635
636 return builtdate
637
638 @staticmethod
639 def _iso_year_start(isoyear):
640 # Given an ISO year, returns the equivalent of the start of the year
641 # on the Gregorian calendar (which is used by Python)
642 # Stolen from:
643 # http://stackoverflow.com/questions/304256/whats-the-best-way-to-find-the-inverse-of-datetime-isocalendar
644
645 # Determine the location of the 4th of January, the first week of
646 # the ISO year is the week containing the 4th of January
647 # http://en.wikipedia.org/wiki/ISO_week_date
648 fourth_jan = datetime.date(isoyear, 1, 4)
649
650 # Note the conversion from ISO day (1 - 7) and Python day (0 - 6)
651 delta = datetime.timedelta(days=fourth_jan.isoweekday() - 1)
652
653 # Return the start of the year
654 return fourth_jan - delta
655
656 @staticmethod
657 def _date_generator(startdate, timedelta, iterations):
658 currentdate = startdate
659 currentiteration = 0
660
661 while currentiteration < iterations:
662 yield currentdate
663
664 # Update the values
665 currentdate += timedelta
666 currentiteration += 1
667
668 @staticmethod
669 def _date_generator_unbounded(startdate, timedelta):
670 currentdate = startdate
671
672 while True:
673 yield currentdate
674
675 # Update the value
676 currentdate += timedelta
677
678 @staticmethod
679 def _distribute_microseconds(todistribute, recipients, reductions):
680 # Given a number of microseconds as int, a tuple of ints length n
681 # to distribute to, and a tuple of ints length n to divide todistribute
682 # by (from largest to smallest), returns a tuple of length n + 1, with
683 # todistribute divided across recipients using the reductions, with
684 # the final remainder returned as the final tuple member
685 results = []
686
687 remainder = todistribute
688
689 for index, reduction in enumerate(reductions):
690 additional, remainder = divmod(remainder, reduction)
691
692 results.append(recipients[index] + additional)
693
694 # Always return the remaining microseconds
695 results.append(remainder)
696
697 return tuple(results)