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