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