1# -*- coding: utf-8 -*- # 2# Copyright 2016 Google LLC. All Rights Reserved. 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16"""dateutil and datetime with portable timezone and ISO 8601 durations. 17 18This module supports round-trip conversions between strings, datetime objects 19and timestamps: 20 21 => ParseDateTime => => GetTimeStampFromDateTime => 22 string datetime timestamp 23 <= FormatDateTime <= <= GetDateTimeFromTimeStamp <= 24 25GetTimeZone(str) returns the tzinfo object for a timezone name. It handles 26abbreviations, IANA timezone names, and on Windows translates timezone names to 27the closest Windows TimeZone registry equivalent. 28 29LocalizeDateTime(datetime, tzinfo) returns a datetime object relative to the 30timezone tzinfo. 31 32ISO 8601 duration/period conversions are also supported: 33 34 => ParseDuration => => GetDateTimePlusDuration => 35 string Duration datetime 36 <= FormatDuration <= 37 38 timedelta => GetDurationFromTimeDelta => Duration 39 40This module is biased to the local timezone by default. To operate on timezone 41naiive datetimes specify tzinfo=None in all calls that have a timezone kwarg. 42 43The datetime and/or dateutil modules should have covered all of this. 44""" 45 46from __future__ import absolute_import 47from __future__ import division 48from __future__ import unicode_literals 49 50import datetime 51import re 52 53from dateutil import parser 54from dateutil import tz 55from dateutil.tz import _common as tz_common 56import enum 57 58from googlecloudsdk.core import exceptions 59from googlecloudsdk.core.util import encoding 60from googlecloudsdk.core.util import iso_duration 61from googlecloudsdk.core.util import times_data 62 63import six 64 65try: 66 from dateutil import tzwin # pylint: disable=g-import-not-at-top, Windows 67except ImportError: 68 tzwin = None 69 70 71class Error(exceptions.Error): 72 """Base errors for this module.""" 73 74 75class DateTimeSyntaxError(Error): 76 """Date/Time string syntax error.""" 77 78 79class DateTimeValueError(Error): 80 """Date/Time part overflow error.""" 81 82 83class DurationSyntaxError(Error): 84 """Duration string syntax error.""" 85 86 87class DurationValueError(Error): 88 """Duration part overflow error.""" 89 90 91tz_common.PY3 = True # MONKEYPATCH!!! Fixes a Python 2 standard module bug. 92 93LOCAL = tz.tzlocal() # The local timezone. 94UTC = tz.tzutc() # The UTC timezone. 95 96 97_MICROSECOND_PRECISION = 6 98 99 100def _StrFtime(dt, fmt): 101 """Convert strftime exceptions to Datetime Errors.""" 102 try: 103 return dt.strftime(fmt) 104 # With dateutil 2.8, strftime() now raises a UnicodeError when it cannot 105 # decode the string as 'ascii' on Python 2. 106 except (TypeError, UnicodeError) as e: 107 if '%Z' not in fmt: 108 raise DateTimeValueError(six.text_type(e)) 109 # Most likely a non-ascii tzname() in python2. Fall back to +-HH:MM. 110 return FormatDateTime(dt, fmt.replace('%Z', '%Ez')) 111 except (AttributeError, OverflowError, ValueError) as e: 112 raise DateTimeValueError(six.text_type(e)) 113 114 115def _StrPtime(string, fmt): 116 """Convert strptime exceptions to Datetime Errors.""" 117 try: 118 return datetime.datetime.strptime(string, fmt) 119 except (AttributeError, OverflowError, TypeError) as e: 120 raise DateTimeValueError(six.text_type(e)) 121 except ValueError as e: 122 raise DateTimeSyntaxError(six.text_type(e)) 123 124 125def FormatDuration(duration, parts=3, precision=3): 126 """Returns an ISO 8601 string representation of the duration. 127 128 The Duration format is: "[-]P[nY][nM][nD][T[nH][nM][n[.m]S]]". At least one 129 part will always be displayed. The 0 duration is "P0". Negative durations 130 are prefixed by "-". "T" disambiguates months "P2M" to the left of "T" and 131 minutes "PT5MM" to the right. 132 133 Args: 134 duration: An iso_duration.Duration object. 135 parts: Format at most this many duration parts starting with largest 136 non-zero part. 137 precision: Format the last duration part with precision digits after the 138 decimal point. Trailing "0" and "." are always stripped. 139 140 Raises: 141 DurationValueError: A Duration numeric constant exceeded its range. 142 143 Returns: 144 An ISO 8601 string representation of the duration. 145 """ 146 return duration.Format(parts=parts, precision=precision) 147 148 149def FormatDurationForJson(duration): 150 """Returns a string representation of the duration, ending in 's'. 151 152 See the section of 153 <https://github.com/google/protobuf/blob/master/src/google/protobuf/duration.proto> 154 on JSON formats. 155 156 For example: 157 158 >>> FormatDurationForJson(iso_duration.Duration(seconds=10)) 159 10s 160 >>> FormatDurationForJson(iso_duration.Duration(hours=1)) 161 3600s 162 >>> FormatDurationForJson(iso_duration.Duration(seconds=1, microseconds=5)) 163 1.000005s 164 165 Args: 166 duration: An iso_duration.Duration object. 167 168 Raises: 169 DurationValueError: A Duration numeric constant exceeded its range. 170 171 Returns: 172 An string representation of the duration. 173 """ 174 # Caution: the default precision for formatting floats is also 6, so when 175 # introducing adjustable precision, make sure to account for that. 176 num = '{}'.format(round(duration.total_seconds, _MICROSECOND_PRECISION)) 177 if num.endswith('.0'): 178 num = num[:-len('.0')] 179 return num + 's' 180 181 182def ParseDuration(string, calendar=False, default_suffix=None): 183 """Parses a duration string and returns a Duration object. 184 185 Durations using only hours, miniutes, seconds and microseconds are exact. 186 calendar=True allows the constructor to use duration units larger than hours. 187 These durations will be inexact across daylight savings time and leap year 188 boundaries, but will be "calendar" correct. For example: 189 190 2015-02-14 + P1Y => 2016-02-14 191 2015-02-14 + P365D => 2016-02-14 192 2016-02-14 + P1Y => 2017-02-14 193 2016-02-14 + P366D => 2017-02-14 194 195 2016-03-13T01:00:00 + P1D => 2016-03-14T01:00:00 196 2016-03-13T01:00:00 + PT23H => 2016-03-14T01:00:00 197 2016-03-13T01:00:00 + PT24H => 2016-03-14T03:00:00 198 199 Args: 200 string: The ISO 8601 duration/period string to parse. 201 calendar: Use duration units larger than hours if True. 202 default_suffix: Use this suffix if string is an unqualified int. 203 204 Raises: 205 DurationSyntaxError: Invalid duration syntax. 206 DurationValueError: A Duration numeric constant exceeded its range. 207 208 Returns: 209 An iso_duration.Duration object for the given ISO 8601 duration/period 210 string. 211 """ 212 if default_suffix: 213 try: 214 seconds = int(string) 215 string = '{}{}'.format(seconds, default_suffix) 216 except ValueError: 217 pass 218 try: 219 return iso_duration.Duration(calendar=calendar).Parse(string) 220 except (AttributeError, OverflowError) as e: 221 raise DurationValueError(six.text_type(e)) 222 except ValueError as e: 223 raise DurationSyntaxError(six.text_type(e)) 224 225 226def GetDurationFromTimeDelta(delta, calendar=False): 227 """Returns a Duration object converted from a datetime.timedelta object. 228 229 Args: 230 delta: The datetime.timedelta object to convert. 231 calendar: Use duration units larger than hours if True. 232 233 Returns: 234 The iso_duration.Duration object converted from a datetime.timedelta object. 235 """ 236 return iso_duration.Duration(delta=delta, calendar=calendar) 237 238 239def GetDateTimePlusDuration(dt, duration): 240 """Returns a new datetime object representing dt + duration. 241 242 Args: 243 dt: The datetime object to add the duration to. 244 duration: The iso_duration.Duration object. 245 246 Returns: 247 A new datetime object representing dt + duration. 248 """ 249 return duration.GetRelativeDateTime(dt) 250 251 252def GetTimeZone(name): 253 """Returns a datetime.tzinfo object for name. 254 255 Args: 256 name: A timezone name string, None for the local timezone. 257 258 Returns: 259 A datetime.tzinfo object for name, local timezone if name is unknown. 260 """ 261 if name in ('UTC', 'Z'): 262 return UTC 263 if name in ('LOCAL', 'L'): 264 return LOCAL 265 name = times_data.ABBREVIATION_TO_IANA.get(name, name) 266 tzinfo = tz.gettz(name) 267 if not tzinfo and tzwin: 268 name = times_data.IANA_TO_WINDOWS.get(name, name) 269 try: 270 tzinfo = tzwin.tzwin(name) 271 except WindowsError: # pylint: disable=undefined-variable 272 pass 273 return tzinfo 274 275 276def FormatDateTime(dt, fmt=None, tzinfo=None): 277 """Returns a string of a datetime object formatted by an extended strftime(). 278 279 fmt handles these modifier extensions to the standard formatting chars: 280 281 %Nf Limit the fractional seconds to N digits. The default is N=6. 282 %Ez Format +/-HHMM offsets as ISO RFC 3339 Z for +0000 otherwise +/-HH:MM. 283 %Oz Format +/-HHMM offsets as ISO RFC 3339 +/-HH:MM. 284 285 NOTE: The standard Python 2 strftime() borks non-ascii time parts. It does 286 so by encoding non-ascii names to bytes, presumably under the assumption that 287 the return value will be immediately output. This code works around that by 288 decoding strftime() values to unicode if necessary and then returning either 289 an ASCII or UNICODE string. 290 291 Args: 292 dt: The datetime object to be formatted. 293 fmt: The strftime(3) format string, None for the RFC 3339 format in the dt 294 timezone ('%Y-%m-%dT%H:%M:%S.%3f%Ez'). 295 tzinfo: Format dt relative to this timezone. 296 297 Raises: 298 DateTimeValueError: A DateTime numeric constant exceeded its range. 299 300 Returns: 301 A string of a datetime object formatted by an extended strftime(). 302 """ 303 if tzinfo: 304 dt = LocalizeDateTime(dt, tzinfo) 305 if not fmt: 306 fmt = '%Y-%m-%dT%H:%M:%S.%3f%Ez' 307 extension = re.compile('%[1-9]?[EO]?[fsz]') 308 m = extension.search(fmt) 309 if not m: 310 return encoding.Decode(_StrFtime(dt, fmt)) 311 312 # Split the format into standard and extension parts. 313 parts = [] 314 start = 0 315 while m: 316 match = start + m.start() 317 if start < match: 318 # Format the preceding standard part. 319 parts.append(encoding.Decode(_StrFtime(dt, fmt[start:match]))) 320 321 # The extensions only have one modifier char. 322 match += 1 323 if fmt[match].isdigit(): 324 n = int(fmt[match]) 325 match += 1 326 else: 327 n = None 328 if fmt[match] in ('E', 'O'): 329 alternate = fmt[match] 330 match += 1 331 else: 332 alternate = None 333 spec = fmt[match] 334 std_fmt = '%' + spec 335 336 if spec == 'f': 337 # Round the fractional part to n digits. 338 val = _StrFtime(dt, std_fmt) 339 if n and n < len(val): 340 # Explicitly avoiding implementation dependent floating point rounding 341 # diffs. 342 v = int(val[:n]) # The rounded value. 343 f = int(val[n]) # The first digit after the rounded value. 344 if f >= 5: 345 # Round up. 346 v += 1 347 zero_fill_format = '{{0:0{n}d}}'.format(n=n) 348 val = zero_fill_format.format(v) 349 if len(val) > n: 350 # All 9's rounded up by 1 overflowed width. Keep the unrounded value. 351 val = zero_fill_format.format(v - 1) 352 elif spec == 's': 353 # datetime.strftime('%s') botches tz aware dt! 354 val = GetTimeStampFromDateTime(dt) 355 elif spec == 'z': 356 # Convert the time zone offset to RFC 3339 format. 357 val = _StrFtime(dt, std_fmt) 358 if alternate: 359 if alternate == 'E' and val == '+0000': 360 val = 'Z' 361 elif len(val) == 5: 362 val = val[:3] + ':' + val[3:] 363 if val: 364 parts.append(encoding.Decode(val)) 365 366 start += m.end() 367 m = extension.search(fmt[start:]) 368 369 # Format the trailing part if any. 370 if start < len(fmt): 371 parts.append(encoding.Decode(_StrFtime(dt, fmt[start:]))) 372 373 # Combine the parts. 374 return ''.join(parts) 375 376 377class _TzInfoOrOffsetGetter(object): 378 """A helper class for dateutil.parser.parse(). 379 380 Attributes: 381 _timezone_was_specified: True if the parsed date/time string contained 382 an explicit timezone name or offset. 383 """ 384 385 def __init__(self): 386 self._timezone_was_specified = False 387 388 def Get(self, name, offset): 389 """Returns the tzinfo for name or offset. 390 391 Used by dateutil.parser.parse() to convert timezone names and offsets. 392 393 Args: 394 name: A timezone name or None to use offset. If offset is also None then 395 the local tzinfo is returned. 396 offset: A signed UTC timezone offset in seconds. 397 398 Returns: 399 The tzinfo for name or offset or the local tzinfo if both are None. 400 """ 401 if name or offset: 402 self._timezone_was_specified = True 403 if not name and offset is not None: 404 return offset 405 return GetTimeZone(name) 406 407 @property 408 def timezone_was_specified(self): 409 """True if the parsed date/time string contained an explicit timezone.""" 410 return self._timezone_was_specified 411 412 413def _SplitTzFromDate(string): 414 """Returns (prefix,tzinfo) if string has a trailing tz, else (None,None).""" 415 try: 416 match = re.match(r'(.*[\d\s])([^\d\s]+)$', string) 417 except TypeError: 418 return None, None 419 if match: 420 tzinfo = GetTimeZone(match.group(2)) 421 if tzinfo: 422 return match.group(1), tzinfo 423 return None, None 424 425 426def ParseDateTime(string, fmt=None, tzinfo=LOCAL): 427 """Parses a date/time string and returns a datetime.datetime object. 428 429 Args: 430 string: The date/time string to parse. This can be a parser.parse() 431 date/time or an ISO 8601 duration after Now(tzinfo) or before if prefixed 432 by '-'. 433 fmt: The input must satisfy this strptime(3) format string. 434 tzinfo: A default timezone tzinfo object to use if string has no timezone. 435 436 Raises: 437 DateTimeSyntaxError: Invalid date/time/duration syntax. 438 DateTimeValueError: A date/time numeric constant exceeds its range. 439 440 Returns: 441 A datetime.datetime object for the given date/time string. 442 """ 443 # Check explicit format first. 444 if fmt: 445 dt = _StrPtime(string, fmt) 446 if tzinfo and not dt.tzinfo: 447 dt = dt.replace(tzinfo=tzinfo) 448 return dt 449 450 # Use tzgetter to determine if string contains an explicit timezone name or 451 # offset. 452 defaults = GetDateTimeDefaults(tzinfo=tzinfo) 453 tzgetter = _TzInfoOrOffsetGetter() 454 455 exc = None 456 try: 457 dt = parser.parse(string, tzinfos=tzgetter.Get, default=defaults) 458 if tzinfo and not tzgetter.timezone_was_specified: 459 # The string had no timezone name or offset => localize dt to tzinfo. 460 dt = parser.parse(string, tzinfos=None, default=defaults) 461 dt = dt.replace(tzinfo=tzinfo) 462 return dt 463 except OverflowError as e: 464 exc = exceptions.ExceptionContext(DateTimeValueError(six.text_type(e))) 465 except (AttributeError, ValueError, TypeError) as e: 466 exc = exceptions.ExceptionContext(DateTimeSyntaxError(six.text_type(e))) 467 if not tzgetter.timezone_was_specified: 468 # Good ole parser.parse() has a tzinfos kwarg that it sometimes ignores. 469 # Compensate here when the string ends with a tz. 470 prefix, explicit_tzinfo = _SplitTzFromDate(string) 471 if explicit_tzinfo: 472 try: 473 dt = parser.parse(prefix, default=defaults) 474 except OverflowError as e: 475 exc = exceptions.ExceptionContext( 476 DateTimeValueError(six.text_type(e))) 477 except (AttributeError, ValueError, TypeError) as e: 478 exc = exceptions.ExceptionContext( 479 DateTimeSyntaxError(six.text_type(e))) 480 else: 481 return dt.replace(tzinfo=explicit_tzinfo) 482 483 try: 484 # Check if it's an iso_duration string. 485 return ParseDuration(string).GetRelativeDateTime(Now(tzinfo=tzinfo)) 486 except Error: 487 # Not a duration - reraise the datetime parse error. 488 exc.Reraise() 489 490 491def GetDateTimeFromTimeStamp(timestamp, tzinfo=LOCAL): 492 """Returns the datetime object for a UNIX timestamp. 493 494 Args: 495 timestamp: A UNIX timestamp in int or float seconds since the epoch 496 (1970-01-01T00:00:00.000000Z). 497 tzinfo: A tzinfo object for the timestamp timezone, None for naive. 498 499 Raises: 500 DateTimeValueError: A date/time numeric constant exceeds its range. 501 502 Returns: 503 The datetime object for a UNIX timestamp. 504 """ 505 try: 506 return datetime.datetime.fromtimestamp(timestamp, tzinfo) 507 # From python 3.3, it raises OverflowError instead of ValueError if the 508 # timestamp is out of the range of values supported by C localtime(). 509 # It raises OSError instead of ValueError on localtime() failure. 510 except (ValueError, OSError, OverflowError) as e: 511 raise DateTimeValueError(six.text_type(e)) 512 513 514def GetTimeStampFromDateTime(dt, tzinfo=LOCAL): 515 """Returns the float UNIX timestamp (with microseconds) for dt. 516 517 Args: 518 dt: The datetime object to convert from. 519 tzinfo: Use this tzinfo if dt is naiive. 520 521 Returns: 522 The float UNIX timestamp (with microseconds) for dt. 523 """ 524 if not dt.tzinfo and tzinfo: 525 dt = dt.replace(tzinfo=tzinfo) 526 delta = dt - datetime.datetime.fromtimestamp(0, UTC) 527 return delta.total_seconds() 528 529 530def LocalizeDateTime(dt, tzinfo=LOCAL): 531 """Returns a datetime object localized to the timezone tzinfo. 532 533 Args: 534 dt: The datetime object to localize. It can be timezone naive or aware. 535 tzinfo: The timezone of the localized dt. If None then the result is naive, 536 otherwise it is aware. 537 538 Returns: 539 A datetime object localized to the timezone tzinfo. 540 """ 541 ts = GetTimeStampFromDateTime(dt, tzinfo=tzinfo) 542 return GetDateTimeFromTimeStamp(ts, tzinfo=tzinfo) 543 544 545def Now(tzinfo=LOCAL): 546 """Returns a timezone aware datetime object for the current time. 547 548 Args: 549 tzinfo: The timezone of the localized dt. If None then the result is naive, 550 otherwise it is aware. 551 552 Returns: 553 A datetime object localized to the timezone tzinfo. 554 """ 555 return datetime.datetime.now(tzinfo) 556 557 558def GetDateTimeDefaults(tzinfo=LOCAL): 559 """Returns a datetime object of default values for parsing partial datetimes. 560 561 The year, month and day default to today (right now), and the hour, minute, 562 second and fractional second values default to 0. 563 564 Args: 565 tzinfo: The timezone of the localized dt. If None then the result is naive, 566 otherwise it is aware. 567 568 Returns: 569 A datetime object of default values for parsing partial datetimes. 570 """ 571 return datetime.datetime.combine(Now(tzinfo=tzinfo).date(), 572 datetime.time.min) 573 574 575def TzOffset(offset, name=None): 576 """Returns a tzinfo for offset minutes east of UTC with optional name. 577 578 Args: 579 offset: The minutes east of UTC. Minutes west are negative. 580 name: The optional timezone name. NOTE: no dst name. 581 582 Returns: 583 A tzinfo for offset seconds east of UTC. 584 """ 585 return tz.tzoffset(name, offset * 60) # tz.tzoffset needs seconds east of UTC 586 587 588class Weekday(enum.Enum): 589 """Represents a day of the week.""" 590 591 MONDAY = 0 592 TUESDAY = 1 593 WEDNESDAY = 2 594 THURSDAY = 3 595 FRIDAY = 4 596 SATURDAY = 5 597 SUNDAY = 6 598 599 @classmethod 600 def Get(cls, day): 601 day = day.upper() 602 value = getattr(cls, day, None) 603 if not value: 604 raise KeyError('[{}] is not a valid Weekday'.format(day)) 605 return value 606 607 608def GetWeekdayInTimezone(dt, weekday, tzinfo=LOCAL): 609 """Returns the Weekday for dt in the timezone specified by tzinfo. 610 611 Args: 612 dt: The datetime object that represents the time on weekday. 613 weekday: The day of the week specified as a Weekday enum. 614 tzinfo: The timezone in which to get the new day of the week in. 615 616 Returns: 617 A Weekday that corresponds to dt and weekday pair localized to the timezone 618 specified by dt. 619 """ 620 localized_dt = LocalizeDateTime(dt, tzinfo) 621 localized_weekday_offset = dt.weekday() - localized_dt.weekday() 622 localized_weekday_index = (weekday.value - localized_weekday_offset) % 7 623 return Weekday(localized_weekday_index) 624