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"""ISO 8601 duration/period support.
17
18https://en.wikipedia.org/wiki/ISO_8601#Durations
19https://tools.ietf.org/html/rfc3339
20
21"""
22
23from __future__ import absolute_import
24from __future__ import division
25from __future__ import unicode_literals
26
27import datetime
28
29
30_DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
31_DAYS_PER_YEAR = 365.2422
32
33_MICROSECONDS_PER_SECOND = 1000000
34_SECONDS_PER_MINUTE = 60
35_MINUTES_PER_HOUR = 60
36_HOURS_PER_DAY = 24
37_MONTHS_PER_YEAR = 12
38
39_SECONDS_PER_HOUR = _SECONDS_PER_MINUTE * _MINUTES_PER_HOUR
40_SECONDS_PER_DAY = _SECONDS_PER_HOUR * _HOURS_PER_DAY
41_SECONDS_PER_YEAR = _SECONDS_PER_DAY * _DAYS_PER_YEAR
42_SECONDS_PER_MONTH = _SECONDS_PER_YEAR / _MONTHS_PER_YEAR
43
44
45def IsLeapYear(year):
46  """Returns True if year is a leap year.
47
48  Cheaper than `import calendar` because its the only thing needed here.
49
50  Args:
51    year: The 4 digit year.
52
53  Returns:
54    True if year is a leap year.
55  """
56  return (year % 400 == 0) or (year % 100 != 0) and (year % 4 == 0)
57
58
59def DaysInCalendarMonth(year, month):
60  """Returns the number of days in the given month and calendar year.
61
62  Args:
63    year: The 4 digit calendar year.
64    month: The month number 1..12.
65
66  Returns:
67    The number of days in the given month and calendar year.
68  """
69  return _DAYS_IN_MONTH[month - 1] + (
70      1 if month == 2 and IsLeapYear(year) else 0)
71
72
73def _FormatNumber(result, number, suffix='', precision=3):
74  """Appends a formatted number + suffix to result.
75
76  Trailing "0" and "." are stripped. If no digits remain then nothing is
77  appended to result.
78
79  Args:
80    result: The formatted number, if any is appended to this list.
81    number: The int or float to format.
82    suffix: A suffix string to append to the number.
83    precision: Format the last duration part with precision digits after the
84      decimal point. Trailing "0" and "." are always stripped.
85  """
86  fmt = '{{0:.{precision}f}}'.format(precision=precision)
87  s = fmt.format(float(number))
88  if precision:
89    s = s.rstrip('0')
90    if s.endswith('.'):
91      s = s[:-1]
92  if s and s != '0':
93    result.append(s + suffix)
94
95
96class Duration(object):
97  """The parts of an ISO 8601 duration plus microseconds.
98
99  Durations using only hours, miniutes, seconds and microseconds are exact.
100  calendar=True allows the constructor to use duration units larger than hours.
101  These durations will be inexact across daylight savings time and leap year
102  boundaries, but will be "calendar" correct. For example:
103
104    2015-02-14 + P1Y   => 2016-02-14
105    2015-02-14 + P365D => 2016-02-14
106    2016-02-14 + P1Y   => 2017-02-14
107    2016-02-14 + P366D => 2017-02-14
108
109    2016-03-13T01:00:00 + P1D   => 2016-03-14T01:00:00
110    2016-03-13T01:00:00 + PT23H => 2016-03-14T01:00:00
111    2016-03-13T01:00:00 + PT24H => 2016-03-14T03:00:00
112
113  delta durations (initialized from datetime.timedelta) are calendar=False.
114  Parsed durations containing duration units larger than hours are
115  calendar=True.
116  """
117
118  def __init__(self, years=0, months=0, days=0, hours=0, minutes=0, seconds=0,
119               microseconds=0, delta=None, calendar=False):
120    self.years = years
121    self.months = months
122    self.days = days
123    self.hours = hours
124    self.minutes = minutes
125    self.seconds = seconds
126    self.microseconds = microseconds
127    self.total_seconds = 0
128    if delta:
129      self.seconds += delta.total_seconds()
130    self.calendar = calendar
131    self._Normalize()
132
133  def _Normalize(self):
134    """Normalizes duration values to integers in ISO 8601 ranges.
135
136    Normalization makes formatted durations aesthetically pleasing. For example,
137    P2H30M0.5S instead of P9000.5S. It also determines if the duration is exact
138    or a calendar duration.
139    """
140
141    # Percolate fractional parts down to self.microseconds.
142
143    # Returns (whole,fraction) of pleasingly rounded f.
144    def _Percolate(f):
145      return int(f), round(f, 4) - int(f)
146
147    self.years, fraction = _Percolate(self.years)
148    if fraction:
149      self.days += _DAYS_PER_YEAR * fraction
150
151    self.months, fraction = _Percolate(self.months)
152    if fraction:
153      # Truncate to integer days because of irregular months.
154      self.days += int(_DAYS_PER_YEAR * fraction / _MONTHS_PER_YEAR)
155
156    self.days, fraction = _Percolate(self.days)
157    if fraction:
158      self.hours += _HOURS_PER_DAY * fraction
159
160    self.hours, fraction = _Percolate(self.hours)
161    if fraction:
162      self.minutes += _MINUTES_PER_HOUR * fraction
163
164    self.minutes, fraction = _Percolate(self.minutes)
165    if fraction:
166      self.seconds += _SECONDS_PER_MINUTE * fraction
167
168    self.seconds, fraction = _Percolate(self.seconds)
169    if fraction:
170      self.microseconds = int(_MICROSECONDS_PER_SECOND * fraction)
171
172    # Adjust ranges to carry over to larger units.
173
174    self.total_seconds = 0.0
175
176    carry = int(self.microseconds / _MICROSECONDS_PER_SECOND)
177    self.microseconds -= int(carry * _MICROSECONDS_PER_SECOND)
178    self.total_seconds += self.microseconds / _MICROSECONDS_PER_SECOND
179    self.seconds += carry
180
181    carry = int(self.seconds / _SECONDS_PER_MINUTE)
182    self.seconds -= carry * _SECONDS_PER_MINUTE
183    self.total_seconds += self.seconds
184    self.minutes += carry
185
186    carry = int(self.minutes / _MINUTES_PER_HOUR)
187    self.minutes -= carry * _MINUTES_PER_HOUR
188    self.total_seconds += self.minutes * _SECONDS_PER_MINUTE
189    self.hours += carry
190
191    if not self.calendar:
192      if self.days or self.months or self.years:
193        self.calendar = True
194      else:
195        self.total_seconds += self.hours * _SECONDS_PER_HOUR
196        return
197
198    carry = int(self.hours / _HOURS_PER_DAY)
199    self.hours -= carry * _HOURS_PER_DAY
200    self.total_seconds += self.hours * _SECONDS_PER_HOUR
201    self.days += carry
202
203    # Carry days over to years because of irregular months. Allow the first
204    # year to have int(_DAYS_PER_YEAR + 1) days, +1 to allow 366 for leap years.
205    if self.days >= int(_DAYS_PER_YEAR + 1):
206      self.days -= int(_DAYS_PER_YEAR + 1)
207      self.years += 1
208    elif self.days <= -int(_DAYS_PER_YEAR + 1):
209      self.days += int(_DAYS_PER_YEAR + 1)
210      self.years -= 1
211    carry = int(self.days / _DAYS_PER_YEAR)
212    self.days -= int(carry * _DAYS_PER_YEAR)
213    self.total_seconds += self.days * _SECONDS_PER_DAY
214    self.years += carry
215
216    carry = int(self.months / _MONTHS_PER_YEAR)
217    self.months -= carry * _MONTHS_PER_YEAR
218    self.total_seconds += self.months * _SECONDS_PER_MONTH
219    self.years += carry
220    self.total_seconds += self.years * _SECONDS_PER_YEAR
221
222    self.total_seconds = (round(self.total_seconds, 0) +
223                          self.microseconds / _MICROSECONDS_PER_SECOND)
224
225  def Parse(self, string):
226    """Parses an ISO 8601 duration from string and returns a Duration object.
227
228    If P is omitted then T is implied (M == minutes).
229
230    Args:
231      string: The ISO 8601 duration string to parse.
232
233    Raises:
234      ValueError: For invalid duration syntax.
235
236    Returns:
237      A Duration object.
238    """
239    s = string.upper()
240    # Signed durations are an extension to the standard. We allow durations to
241    # be intialized from signed datetime.timdelta objects, so we must either
242    # allow negative durations or make them an error. This supports interval
243    # notations like "modify-time / -P7D" for "changes older than 1 week" or
244    # "-P7D" for "1 week ago". These cannot be specified in ISO notation.
245    t_separator = False  # 'T' separator was seen.
246    t_implied = False  # Already saw months or smaller part.
247    if s.startswith('-'):
248      s = s[1:]
249      sign = '-'
250    else:
251      if s.startswith('+'):
252        s = s[1:]
253      sign = ''
254    if s.startswith('P'):
255      s = s[1:]
256    else:
257      t_implied = True
258    amount = [sign]
259    for i, c in enumerate(s):
260      if c.isdigit():
261        amount.append(c)
262      elif c == '.' or c == ',':
263        amount.append('.')
264      elif c == 'T':
265        if t_separator:
266          raise ValueError("A duration may contain at most one 'T' separator.")
267        t_separator = t_implied = True
268      elif len(amount) == 1:
269        raise ValueError(
270            "Duration unit '{}' must be preceded by a number.".format(
271                string[i:]))
272      else:
273        number = float(''.join(amount))
274        amount = [sign]
275        if c == 'Y':
276          self.years += number
277        elif c == 'W':
278          self.days += number * 7
279        elif c == 'D':
280          self.days += number
281        elif c in ('M', 'U', 'N') and len(s) == i + 2 and s[i + 1] == 'S':
282          # ms, us, ns OK if it's the last part.
283          if c == 'M':
284            n = 1000
285          elif c == 'U':
286            n = 1000000
287          else:
288            n = 1000000000
289          self.seconds += number / n
290          break
291        elif c == 'M' and not t_implied:
292          t_implied = True
293          self.months += number
294        else:
295          t_implied = True
296          if c == 'H':
297            self.hours += number
298          elif c == 'M':
299            self.minutes += number
300          elif c == 'S':
301            self.seconds += number
302          else:
303            raise ValueError("Unknown character '{0}' in duration.".format(c))
304    if len(amount) > 1 and string.upper().lstrip('+-') != 'P0':
305      raise ValueError('Duration must end with time part character.')
306    self._Normalize()
307    return self
308
309  def Format(self, parts=3, precision=3):
310    """Returns an ISO 8601 string representation of the duration.
311
312    The Duration format is: "[-]P[nY][nM][nD][T[nH][nM][n[.m]S]]". The 0
313    duration is "P0". Otherwise at least one part will always be displayed.
314    Negative durations are prefixed by "-". "T" disambiguates months "P2M" to
315    the left of "T" and minutes "PT5M" to the right.
316
317    Args:
318      parts: Format at most this many duration parts starting with largest
319        non-zero part, 0 for all parts. Zero-valued parts in the count are not
320        shown.
321      precision: Format the last duration part with precision digits after the
322        decimal point. Trailing "0" and "." are always stripped.
323
324    Returns:
325      An ISO 8601 string representation of the duration.
326    """
327    if parts <= 0:
328      parts = 7
329    total_seconds = abs(self.total_seconds)
330    count = 0
331    shown = 0
332    result = []
333    if self.total_seconds < 0:
334      result.append('-')
335    result.append('P')
336
337    if count < parts and self.years:
338      shown = 1
339      n = abs(self.years)
340      total_seconds -= n * _SECONDS_PER_YEAR
341      if count >= parts - 1:
342        n += total_seconds / _SECONDS_PER_YEAR
343      _FormatNumber(result, n, 'Y', precision=0)
344    count += shown
345
346    if count < parts and self.months:
347      shown = 1
348      n = abs(self.months)
349      total_seconds -= n * _SECONDS_PER_MONTH
350      if count >= parts - 1:
351        n += total_seconds / _SECONDS_PER_MONTH
352      _FormatNumber(result, n, 'M', precision=0)
353    count += shown
354
355    if count < parts and self.days:
356      shown = 1
357      n = abs(self.days)
358      total_seconds -= n * _SECONDS_PER_DAY
359      if count >= parts - 1:
360        n += total_seconds / _SECONDS_PER_DAY
361      _FormatNumber(result, n, 'D', precision=0)
362    result.append('T')
363    count += shown
364
365    if count < parts and self.hours:
366      shown = 1
367      n = abs(self.hours)
368      total_seconds -= n * _SECONDS_PER_HOUR
369      if count >= parts - 1:
370        n += total_seconds / _SECONDS_PER_HOUR
371      _FormatNumber(result, n, 'H', precision=0)
372    count += shown
373
374    if count < parts and self.minutes:
375      shown = 1
376      n = abs(self.minutes)
377      total_seconds -= n * _SECONDS_PER_MINUTE
378      if count >= parts - 1:
379        n += total_seconds / _SECONDS_PER_MINUTE
380      _FormatNumber(result, n, 'M', precision=0)
381    count += shown
382
383    if count < parts and (self.seconds or self.microseconds):
384      count += 1
385      _FormatNumber(result,
386                    (abs(self.seconds) + abs(self.microseconds) /
387                     _MICROSECONDS_PER_SECOND),
388                    'S',
389                    precision=precision)
390
391    # No dangling 'T'.
392    if result[-1] == 'T':
393      result = result[:-1]
394    # 'P0' is the zero duration.
395    if result[-1] == 'P':
396      result.append('0')
397    return ''.join(result)
398
399  def AddTimeDelta(self, delta, calendar=None):
400    """Adds a datetime.timdelta to the duration.
401
402    Args:
403      delta: A datetime.timedelta object to add.
404      calendar: Use duration units larger than hours if True.
405
406    Returns:
407      The modified Duration (self).
408    """
409    if calendar is not None:
410      self.calendar = calendar
411    self.seconds += delta.total_seconds()
412    self._Normalize()
413    return self
414
415  def GetRelativeDateTime(self, dt):
416    """Returns a copy of the datetime object dt relative to the duration.
417
418    Args:
419      dt: The datetime object to add the duration to.
420
421    Returns:
422      The a copy of datetime object dt relative to the duration.
423    """
424    # Add the duration parts to the new dt parts and normalize to valid ranges.
425
426    # All parts are normalized so abs(underflow) and abs(overflow) must be <
427    # 2 * the max normalized value.
428
429    microsecond, second, minute, hour, day, month, year = (
430        dt.microsecond, dt.second, dt.minute, dt.hour, dt.day, dt.month, dt.year
431    )
432
433    microsecond += self.microseconds
434    if microsecond >= _MICROSECONDS_PER_SECOND:
435      microsecond -= _MICROSECONDS_PER_SECOND
436      second += 1
437    elif microsecond < 0:
438      microsecond += _MICROSECONDS_PER_SECOND
439      second -= 1
440
441    second += self.seconds
442    if second >= _SECONDS_PER_MINUTE:
443      second -= _SECONDS_PER_MINUTE
444      minute += 1
445    elif second < 0:
446      second += _SECONDS_PER_MINUTE
447      minute -= 1
448
449    minute += self.minutes
450    if minute >= _MINUTES_PER_HOUR:
451      minute -= _MINUTES_PER_HOUR
452      hour += 1
453    elif minute < 0:
454      minute += _MINUTES_PER_HOUR
455      hour -= 1
456
457    # Non-calendar hours can be > 23 so we normalize here.
458    carry = int((hour + self.hours) / _HOURS_PER_DAY)
459    hour += self.hours - carry * _HOURS_PER_DAY
460    if hour < 0:
461      hour += _HOURS_PER_DAY
462      carry -= 1
463    day += carry
464
465    # Adjust the year before days and months because of irregular months.
466    month += self.months
467    if month > _MONTHS_PER_YEAR:
468      month -= _MONTHS_PER_YEAR
469      year += 1
470    elif month < 1:
471      month += _MONTHS_PER_YEAR
472      year -= 1
473
474    year += self.years
475
476    # Normalized days duration range is 0.._DAYS_PER_YEAR+1 because of
477    # irregular months and leap years.
478    day += self.days
479    if day < 1:
480      while day < 1:
481        month -= 1
482        if month < 1:
483          month = _MONTHS_PER_YEAR
484          year -= 1
485        day += DaysInCalendarMonth(year, month)
486    else:
487      while True:
488        days_in_month = DaysInCalendarMonth(year, month)
489        if day <= days_in_month:
490          break
491        day -= days_in_month
492        month += 1
493        if month > _MONTHS_PER_YEAR:
494          month = 1
495          year += 1
496
497    return datetime.datetime(
498        year, month, day, hour, minute, second, microsecond, dt.tzinfo)
499