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