1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2019, 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
10import math
11
12from aniso8601.builders import BaseTimeBuilder, TupleBuilder
13from aniso8601.exceptions import (DayOutOfBoundsError,
14                                  HoursOutOfBoundsError,
15                                  LeapSecondError, MidnightBoundsError,
16                                  MinutesOutOfBoundsError,
17                                  SecondsOutOfBoundsError,
18                                  WeekOutOfBoundsError, YearOutOfBoundsError)
19from aniso8601.utcoffset import UTCOffset
20
21class PythonTimeBuilder(BaseTimeBuilder):
22    @classmethod
23    def build_date(cls, YYYY=None, MM=None, DD=None, Www=None, D=None,
24                   DDD=None):
25
26        if YYYY is not None:
27            #Truncated dates, like '19', refer to 1900-1999 inclusive,
28            #we simply parse to 1900
29            if len(YYYY) < 4:
30                #Shift 0s in from the left to form complete year
31                YYYY = YYYY.ljust(4, '0')
32
33            year = cls.cast(YYYY, int,
34                            thrownmessage='Invalid year string.')
35
36        if MM is not None:
37            month = cls.cast(MM, int,
38                             thrownmessage='Invalid month string.')
39        else:
40            month = 1
41
42        if DD is not None:
43            day = cls.cast(DD, int,
44                           thrownmessage='Invalid day string.')
45        else:
46            day = 1
47
48        if Www is not None:
49            weeknumber = cls.cast(Www, int,
50                                  thrownmessage='Invalid week string.')
51
52            if weeknumber == 0 or weeknumber > 53:
53                raise WeekOutOfBoundsError('Week number must be between '
54                                           '1..53.')
55        else:
56            weeknumber = None
57
58        if DDD is not None:
59            dayofyear = cls.cast(DDD, int,
60                                 thrownmessage='Invalid day string.')
61        else:
62            dayofyear = None
63
64        if D is not None:
65            dayofweek = cls.cast(D, int,
66                                 thrownmessage='Invalid day string.')
67
68            if dayofweek == 0 or dayofweek > 7:
69                raise DayOutOfBoundsError('Weekday number must be between '
70                                          '1..7.')
71        else:
72            dayofweek = None
73
74        #0000 (1 BC) is not representable as a Python date so a ValueError is
75        #raised
76        if year == 0:
77            raise YearOutOfBoundsError('Year must be between 1..9999.')
78
79        if dayofyear is not None:
80            return PythonTimeBuilder._build_ordinal_date(year, dayofyear)
81        elif weeknumber is not None:
82            return PythonTimeBuilder._build_week_date(year, weeknumber,
83                                                      isoday=dayofweek)
84
85        return datetime.date(year, month, day)
86
87    @classmethod
88    def build_time(cls, hh=None, mm=None, ss=None, tz=None):
89        #Builds a time from the given parts, handling fractional arguments
90        #where necessary
91        hours = 0
92        minutes = 0
93        seconds = 0
94
95        floathours = float(0)
96        floatminutes = float(0)
97        floatseconds = float(0)
98
99        if hh is not None:
100            if '.' in hh:
101                hours, floathours = cls._split_and_cast(hh, 'Invalid hour string.')
102            else:
103                hours = cls.cast(hh, int,
104                                 thrownmessage='Invalid hour string.')
105
106        if mm is not None:
107            if '.' in mm:
108                minutes, floatminutes = cls._split_and_cast(mm, 'Invalid minute string.')
109            else:
110                minutes = cls.cast(mm, int,
111                                   thrownmessage='Invalid minute string.')
112
113        if ss is not None:
114            if '.' in ss:
115                seconds, floatseconds = cls._split_and_cast(ss, 'Invalid second string.')
116            else:
117                seconds = cls.cast(ss, int,
118                                   thrownmessage='Invalid second string.')
119
120        if floathours != 0:
121            remainderhours, remainderminutes = cls._split_and_convert(floathours, 60)
122
123            hours += remainderhours
124            floatminutes += remainderminutes
125
126        if floatminutes != 0:
127            remainderminutes, remainderseconds = cls._split_and_convert(floatminutes, 60)
128
129            minutes += remainderminutes
130            floatseconds += remainderseconds
131
132        if floatseconds != 0:
133            totalseconds = float(seconds) + floatseconds
134
135            #Truncate to maximum supported precision
136            seconds = cls._truncate(totalseconds, 6)
137
138        #Range checks
139        if hours == 23 and minutes == 59 and seconds == 60:
140            #https://bitbucket.org/nielsenb/aniso8601/issues/10/sub-microsecond-precision-in-durations-is
141            raise LeapSecondError('Leap seconds are not supported.')
142
143        if (hours == 24
144                and (minutes != 0 or seconds != 0)):
145            raise MidnightBoundsError('Hour 24 may only represent midnight.')
146
147        if hours > 24:
148            raise HoursOutOfBoundsError('Hour must be between 0..24 with '
149                                        '24 representing midnight.')
150
151        if minutes >= 60:
152            raise MinutesOutOfBoundsError('Minutes must be less than 60.')
153
154        if seconds >= 60:
155            raise SecondsOutOfBoundsError('Seconds must be less than 60.')
156
157        #Fix ranges that have passed range checks
158        if hours == 24:
159            hours = 0
160            minutes = 0
161            seconds = 0
162
163        #Datetimes don't handle fractional components, so we use a timedelta
164        if tz is not None:
165            return (datetime.datetime(1, 1, 1,
166                                      hour=hours,
167                                      minute=minutes,
168                                      tzinfo=cls._build_object(tz))
169                    + datetime.timedelta(seconds=seconds)
170                   ).timetz()
171
172        return (datetime.datetime(1, 1, 1,
173                                  hour=hours,
174                                  minute=minutes)
175                + datetime.timedelta(seconds=seconds)
176               ).time()
177
178    @classmethod
179    def build_datetime(cls, date, time):
180        return datetime.datetime.combine(cls._build_object(date),
181                                         cls._build_object(time))
182
183    @classmethod
184    def build_duration(cls, PnY=None, PnM=None, PnW=None, PnD=None, TnH=None,
185                       TnM=None, TnS=None):
186        years = 0
187        months = 0
188        days = 0
189        weeks = 0
190        hours = 0
191        minutes = 0
192        seconds = 0
193
194        floatyears = float(0)
195        floatmonths = float(0)
196        floatdays = float(0)
197        floatweeks = float(0)
198        floathours = float(0)
199        floatminutes = float(0)
200        floatseconds = float(0)
201
202        if PnY is not None:
203            if '.' in PnY:
204                years, floatyears = cls._split_and_cast(PnY, 'Invalid year string.')
205            else:
206                years = cls.cast(PnY, int,
207                                 thrownmessage='Invalid year string.')
208
209        if PnM is not None:
210            if '.' in PnM:
211                months, floatmonths = cls._split_and_cast(PnM, 'Invalid month string.')
212            else:
213                months = cls.cast(PnM, int,
214                                  thrownmessage='Invalid month string.')
215
216        if PnW is not None:
217            if '.' in PnW:
218                weeks, floatweeks = cls._split_and_cast(PnW, 'Invalid week string.')
219            else:
220                weeks = cls.cast(PnW, int,
221                                 thrownmessage='Invalid week string.')
222
223        if PnD is not None:
224            if '.' in PnD:
225                days, floatdays = cls._split_and_cast(PnD, 'Invalid day string.')
226            else:
227                days = cls.cast(PnD, int,
228                                thrownmessage='Invalid day string.')
229
230        if TnH is not None:
231            if '.' in TnH:
232                hours, floathours = cls._split_and_cast(TnH, 'Invalid hour string.')
233            else:
234                hours = cls.cast(TnH, int,
235                                 thrownmessage='Invalid hour string.')
236
237        if TnM is not None:
238            if '.' in TnM:
239                minutes, floatminutes = cls._split_and_cast(TnM, 'Invalid minute string.')
240            else:
241                minutes = cls.cast(TnM, int,
242                                   thrownmessage='Invalid minute string.')
243
244        if TnS is not None:
245            if '.' in TnS:
246                seconds, floatseconds = cls._split_and_cast(TnS, 'Invalid second string.')
247            else:
248                seconds = cls.cast(TnS, int,
249                                   thrownmessage='Invalid second string.')
250
251        if floatyears != 0:
252            remainderyears, remainderdays = cls._split_and_convert(floatyears, 365)
253
254            years += remainderyears
255            floatdays += remainderdays
256
257        if floatmonths != 0:
258            remaindermonths, remainderdays = cls._split_and_convert(floatmonths, 30)
259
260            months += remaindermonths
261            floatdays += remainderdays
262
263        if floatweeks != 0:
264            remainderweeks, remainderdays = cls._split_and_convert(floatweeks, 7)
265
266            weeks += remainderweeks
267            floatdays += remainderdays
268
269        if floatdays != 0:
270            remainderdays, remainderhours = cls._split_and_convert(floatdays, 24)
271
272            days += remainderdays
273            floathours += remainderhours
274
275        if floathours != 0:
276            remainderhours, remainderminutes = cls._split_and_convert(floathours, 60)
277
278            hours += remainderhours
279            floatminutes += remainderminutes
280
281        if floatminutes != 0:
282            remainderminutes, remainderseconds = cls._split_and_convert(floatminutes, 60)
283
284            minutes += remainderminutes
285            floatseconds += remainderseconds
286
287        if floatseconds != 0:
288            totalseconds = float(seconds) + floatseconds
289
290            #Truncate to maximum supported precision
291            seconds = cls._truncate(totalseconds, 6)
292
293        #Note that weeks can be handled without conversion to days
294        totaldays = years * 365 + months * 30 + days
295
296        return datetime.timedelta(days=totaldays,
297                                  seconds=seconds,
298                                  minutes=minutes,
299                                  hours=hours,
300                                  weeks=weeks)
301
302    @classmethod
303    def build_interval(cls, start=None, end=None, duration=None):
304        if start is not None and end is not None:
305            #<start>/<end>
306            startobject = cls._build_object(start)
307            endobject = cls._build_object(end)
308
309            return (startobject, endobject)
310
311        durationobject = cls._build_object(duration)
312
313        #Determine if datetime promotion is required
314        datetimerequired = (duration[4] is not None
315                            or duration[5] is not None
316                            or duration[6] is not None
317                            or durationobject.seconds != 0
318                            or durationobject.microseconds != 0)
319
320        if end is not None:
321            #<duration>/<end>
322            endobject = cls._build_object(end)
323            if end[-1] == 'date' and datetimerequired is True:
324                #<end> is a date, and <duration> requires datetime resolution
325                return (endobject,
326                        cls.build_datetime(end, TupleBuilder.build_time())
327                        - durationobject)
328
329            return (endobject,
330                    endobject
331                    - durationobject)
332
333        #<start>/<duration>
334        startobject = cls._build_object(start)
335
336        if start[-1] == 'date' and datetimerequired is True:
337            #<start> is a date, and <duration> requires datetime resolution
338            return (startobject,
339                    cls.build_datetime(start, TupleBuilder.build_time())
340                    + durationobject)
341
342        return (startobject,
343                startobject
344                + durationobject)
345
346    @classmethod
347    def build_repeating_interval(cls, R=None, Rnn=None, interval=None):
348        startobject = None
349        endobject = None
350
351        if interval[0] is not None:
352            startobject = cls._build_object(interval[0])
353
354        if interval[1] is not None:
355            endobject = cls._build_object(interval[1])
356
357        if interval[2] is not None:
358            durationobject = cls._build_object(interval[2])
359        else:
360            durationobject = endobject - startobject
361
362        if R is True:
363            if startobject is not None:
364                return cls._date_generator_unbounded(startobject,
365                                                     durationobject)
366
367            return cls._date_generator_unbounded(endobject,
368                                                 -durationobject)
369
370        iterations = cls.cast(Rnn, int,
371                              thrownmessage='Invalid iterations.')
372
373        if startobject is not None:
374            return cls._date_generator(startobject, durationobject, iterations)
375
376        return cls._date_generator(endobject, -durationobject, iterations)
377
378    @classmethod
379    def build_timezone(cls, negative=None, Z=None, hh=None, mm=None, name=''):
380        if Z is True:
381            #Z -> UTC
382            return UTCOffset(name='UTC', minutes=0)
383
384        if hh is not None:
385            tzhour = cls.cast(hh, int,
386                              thrownmessage='Invalid hour string.')
387        else:
388            tzhour = 0
389
390        if mm is not None:
391            tzminute = cls.cast(mm, int,
392                                thrownmessage='Invalid minute string.')
393        else:
394            tzminute = 0
395
396        if negative is True:
397            return UTCOffset(name=name, minutes=-(tzhour * 60 + tzminute))
398
399        return UTCOffset(name=name, minutes=tzhour * 60 + tzminute)
400
401    @staticmethod
402    def _build_week_date(isoyear, isoweek, isoday=None):
403        if isoday is None:
404            return (PythonTimeBuilder._iso_year_start(isoyear)
405                    + datetime.timedelta(weeks=isoweek - 1))
406
407        return (PythonTimeBuilder._iso_year_start(isoyear)
408                + datetime.timedelta(weeks=isoweek - 1, days=isoday - 1))
409
410    @staticmethod
411    def _build_ordinal_date(isoyear, isoday):
412        #Day of year to a date
413        #https://stackoverflow.com/questions/2427555/python-question-year-and-day-of-year-to-date
414        builtdate = (datetime.date(isoyear, 1, 1)
415                     + datetime.timedelta(days=isoday - 1))
416
417        #Enforce ordinal day limitation
418        #https://bitbucket.org/nielsenb/aniso8601/issues/14/parsing-ordinal-dates-should-only-allow
419        if isoday == 0 or builtdate.year != isoyear:
420            raise DayOutOfBoundsError('Day of year must be from 1..365, '
421                                      '1..366 for leap year.')
422
423        return builtdate
424
425    @staticmethod
426    def _iso_year_start(isoyear):
427        #Given an ISO year, returns the equivalent of the start of the year
428        #on the Gregorian calendar (which is used by Python)
429        #Stolen from:
430        #http://stackoverflow.com/questions/304256/whats-the-best-way-to-find-the-inverse-of-datetime-isocalendar
431
432        #Determine the location of the 4th of January, the first week of
433        #the ISO year is the week containing the 4th of January
434        #http://en.wikipedia.org/wiki/ISO_week_date
435        fourth_jan = datetime.date(isoyear, 1, 4)
436
437        #Note the conversion from ISO day (1 - 7) and Python day (0 - 6)
438        delta = datetime.timedelta(days=fourth_jan.isoweekday() - 1)
439
440        #Return the start of the year
441        return fourth_jan - delta
442
443    @staticmethod
444    def _date_generator(startdate, timedelta, iterations):
445        currentdate = startdate
446        currentiteration = 0
447
448        while currentiteration < iterations:
449            yield currentdate
450
451            #Update the values
452            currentdate += timedelta
453            currentiteration += 1
454
455    @staticmethod
456    def _date_generator_unbounded(startdate, timedelta):
457        currentdate = startdate
458
459        while True:
460            yield currentdate
461
462            #Update the value
463            currentdate += timedelta
464
465    @classmethod
466    def _split_and_cast(cls, floatstr, thrownmessage):
467        #Splits a string with a decimal point into int, and
468        #float portions
469        intpart, floatpart = floatstr.split('.')
470
471        intvalue = cls.cast(intpart, int,
472                            thrownmessage=thrownmessage)
473
474        floatvalue = cls.cast('.' + floatpart, float,
475                              thrownmessage=thrownmessage)
476
477        return (intvalue, floatvalue)
478
479    @staticmethod
480    def _split_and_convert(f, conversion):
481        #Splits a float into an integer, and a converted float portion
482        floatpart, integerpart = math.modf(f)
483
484        return (int(integerpart), float(floatpart) * conversion)
485
486    @staticmethod
487    def _truncate(f, n):
488        #Truncates/pads a float f to n decimal places without rounding
489        #https://stackoverflow.com/a/783927
490        #This differs from the given implementation in that we expand the string
491        #two additional characters, than truncate the resulting string
492        #to mitigate rounding effects
493        floatstr = repr(f)
494
495        if 'e' in floatstr or 'E' in floatstr:
496            expandedfloatstr = '{0:.{1}f}'.format(f, n + 2)
497        else:
498            integerpartstr, _, floatpartstr = floatstr.partition('.')
499
500            expandedfloatstr = '.'.join([integerpartstr,
501                                         (floatpartstr
502                                          + '0' * (n + 2))[:n + 2]])
503
504        return float(expandedfloatstr[:expandedfloatstr.index('.') + n + 1])
505