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