mercurial/utils/dateutil.py
changeset 36607 c6061cadb400
parent 36588 281f66777ff0
child 40256 d4d2c567bb72
equal deleted inserted replaced
36606:4de15c54e59f 36607:c6061cadb400
       
     1 # util.py - Mercurial utility functions relative to dates
       
     2 #
       
     3 #  Copyright 2018 Boris Feld <boris.feld@octobus.net>
       
     4 #
       
     5 # This software may be used and distributed according to the terms of the
       
     6 # GNU General Public License version 2 or any later version.
       
     7 
       
     8 from __future__ import absolute_import, print_function
       
     9 
       
    10 import calendar
       
    11 import datetime
       
    12 import time
       
    13 
       
    14 from ..i18n import _
       
    15 from .. import (
       
    16     encoding,
       
    17     error,
       
    18     pycompat,
       
    19 )
       
    20 
       
    21 # used by parsedate
       
    22 defaultdateformats = (
       
    23     '%Y-%m-%dT%H:%M:%S', # the 'real' ISO8601
       
    24     '%Y-%m-%dT%H:%M',    #   without seconds
       
    25     '%Y-%m-%dT%H%M%S',   # another awful but legal variant without :
       
    26     '%Y-%m-%dT%H%M',     #   without seconds
       
    27     '%Y-%m-%d %H:%M:%S', # our common legal variant
       
    28     '%Y-%m-%d %H:%M',    #   without seconds
       
    29     '%Y-%m-%d %H%M%S',   # without :
       
    30     '%Y-%m-%d %H%M',     #   without seconds
       
    31     '%Y-%m-%d %I:%M:%S%p',
       
    32     '%Y-%m-%d %H:%M',
       
    33     '%Y-%m-%d %I:%M%p',
       
    34     '%Y-%m-%d',
       
    35     '%m-%d',
       
    36     '%m/%d',
       
    37     '%m/%d/%y',
       
    38     '%m/%d/%Y',
       
    39     '%a %b %d %H:%M:%S %Y',
       
    40     '%a %b %d %I:%M:%S%p %Y',
       
    41     '%a, %d %b %Y %H:%M:%S',        #  GNU coreutils "/bin/date --rfc-2822"
       
    42     '%b %d %H:%M:%S %Y',
       
    43     '%b %d %I:%M:%S%p %Y',
       
    44     '%b %d %H:%M:%S',
       
    45     '%b %d %I:%M:%S%p',
       
    46     '%b %d %H:%M',
       
    47     '%b %d %I:%M%p',
       
    48     '%b %d %Y',
       
    49     '%b %d',
       
    50     '%H:%M:%S',
       
    51     '%I:%M:%S%p',
       
    52     '%H:%M',
       
    53     '%I:%M%p',
       
    54 )
       
    55 
       
    56 extendeddateformats = defaultdateformats + (
       
    57     "%Y",
       
    58     "%Y-%m",
       
    59     "%b",
       
    60     "%b %Y",
       
    61 )
       
    62 
       
    63 def makedate(timestamp=None):
       
    64     '''Return a unix timestamp (or the current time) as a (unixtime,
       
    65     offset) tuple based off the local timezone.'''
       
    66     if timestamp is None:
       
    67         timestamp = time.time()
       
    68     if timestamp < 0:
       
    69         hint = _("check your clock")
       
    70         raise error.Abort(_("negative timestamp: %d") % timestamp, hint=hint)
       
    71     delta = (datetime.datetime.utcfromtimestamp(timestamp) -
       
    72              datetime.datetime.fromtimestamp(timestamp))
       
    73     tz = delta.days * 86400 + delta.seconds
       
    74     return timestamp, tz
       
    75 
       
    76 def datestr(date=None, format='%a %b %d %H:%M:%S %Y %1%2'):
       
    77     """represent a (unixtime, offset) tuple as a localized time.
       
    78     unixtime is seconds since the epoch, and offset is the time zone's
       
    79     number of seconds away from UTC.
       
    80 
       
    81     >>> datestr((0, 0))
       
    82     'Thu Jan 01 00:00:00 1970 +0000'
       
    83     >>> datestr((42, 0))
       
    84     'Thu Jan 01 00:00:42 1970 +0000'
       
    85     >>> datestr((-42, 0))
       
    86     'Wed Dec 31 23:59:18 1969 +0000'
       
    87     >>> datestr((0x7fffffff, 0))
       
    88     'Tue Jan 19 03:14:07 2038 +0000'
       
    89     >>> datestr((-0x80000000, 0))
       
    90     'Fri Dec 13 20:45:52 1901 +0000'
       
    91     """
       
    92     t, tz = date or makedate()
       
    93     if "%1" in format or "%2" in format or "%z" in format:
       
    94         sign = (tz > 0) and "-" or "+"
       
    95         minutes = abs(tz) // 60
       
    96         q, r = divmod(minutes, 60)
       
    97         format = format.replace("%z", "%1%2")
       
    98         format = format.replace("%1", "%c%02d" % (sign, q))
       
    99         format = format.replace("%2", "%02d" % r)
       
   100     d = t - tz
       
   101     if d > 0x7fffffff:
       
   102         d = 0x7fffffff
       
   103     elif d < -0x80000000:
       
   104         d = -0x80000000
       
   105     # Never use time.gmtime() and datetime.datetime.fromtimestamp()
       
   106     # because they use the gmtime() system call which is buggy on Windows
       
   107     # for negative values.
       
   108     t = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=d)
       
   109     s = encoding.strtolocal(t.strftime(encoding.strfromlocal(format)))
       
   110     return s
       
   111 
       
   112 def shortdate(date=None):
       
   113     """turn (timestamp, tzoff) tuple into iso 8631 date."""
       
   114     return datestr(date, format='%Y-%m-%d')
       
   115 
       
   116 def parsetimezone(s):
       
   117     """find a trailing timezone, if any, in string, and return a
       
   118        (offset, remainder) pair"""
       
   119     s = pycompat.bytestr(s)
       
   120 
       
   121     if s.endswith("GMT") or s.endswith("UTC"):
       
   122         return 0, s[:-3].rstrip()
       
   123 
       
   124     # Unix-style timezones [+-]hhmm
       
   125     if len(s) >= 5 and s[-5] in "+-" and s[-4:].isdigit():
       
   126         sign = (s[-5] == "+") and 1 or -1
       
   127         hours = int(s[-4:-2])
       
   128         minutes = int(s[-2:])
       
   129         return -sign * (hours * 60 + minutes) * 60, s[:-5].rstrip()
       
   130 
       
   131     # ISO8601 trailing Z
       
   132     if s.endswith("Z") and s[-2:-1].isdigit():
       
   133         return 0, s[:-1]
       
   134 
       
   135     # ISO8601-style [+-]hh:mm
       
   136     if (len(s) >= 6 and s[-6] in "+-" and s[-3] == ":" and
       
   137         s[-5:-3].isdigit() and s[-2:].isdigit()):
       
   138         sign = (s[-6] == "+") and 1 or -1
       
   139         hours = int(s[-5:-3])
       
   140         minutes = int(s[-2:])
       
   141         return -sign * (hours * 60 + minutes) * 60, s[:-6]
       
   142 
       
   143     return None, s
       
   144 
       
   145 def strdate(string, format, defaults=None):
       
   146     """parse a localized time string and return a (unixtime, offset) tuple.
       
   147     if the string cannot be parsed, ValueError is raised."""
       
   148     if defaults is None:
       
   149         defaults = {}
       
   150 
       
   151     # NOTE: unixtime = localunixtime + offset
       
   152     offset, date = parsetimezone(string)
       
   153 
       
   154     # add missing elements from defaults
       
   155     usenow = False # default to using biased defaults
       
   156     for part in ("S", "M", "HI", "d", "mb", "yY"): # decreasing specificity
       
   157         part = pycompat.bytestr(part)
       
   158         found = [True for p in part if ("%"+p) in format]
       
   159         if not found:
       
   160             date += "@" + defaults[part][usenow]
       
   161             format += "@%" + part[0]
       
   162         else:
       
   163             # We've found a specific time element, less specific time
       
   164             # elements are relative to today
       
   165             usenow = True
       
   166 
       
   167     timetuple = time.strptime(encoding.strfromlocal(date),
       
   168                               encoding.strfromlocal(format))
       
   169     localunixtime = int(calendar.timegm(timetuple))
       
   170     if offset is None:
       
   171         # local timezone
       
   172         unixtime = int(time.mktime(timetuple))
       
   173         offset = unixtime - localunixtime
       
   174     else:
       
   175         unixtime = localunixtime + offset
       
   176     return unixtime, offset
       
   177 
       
   178 def parsedate(date, formats=None, bias=None):
       
   179     """parse a localized date/time and return a (unixtime, offset) tuple.
       
   180 
       
   181     The date may be a "unixtime offset" string or in one of the specified
       
   182     formats. If the date already is a (unixtime, offset) tuple, it is returned.
       
   183 
       
   184     >>> parsedate(b' today ') == parsedate(
       
   185     ...     datetime.date.today().strftime('%b %d').encode('ascii'))
       
   186     True
       
   187     >>> parsedate(b'yesterday ') == parsedate(
       
   188     ...     (datetime.date.today() - datetime.timedelta(days=1)
       
   189     ...      ).strftime('%b %d').encode('ascii'))
       
   190     True
       
   191     >>> now, tz = makedate()
       
   192     >>> strnow, strtz = parsedate(b'now')
       
   193     >>> (strnow - now) < 1
       
   194     True
       
   195     >>> tz == strtz
       
   196     True
       
   197     """
       
   198     if bias is None:
       
   199         bias = {}
       
   200     if not date:
       
   201         return 0, 0
       
   202     if isinstance(date, tuple) and len(date) == 2:
       
   203         return date
       
   204     if not formats:
       
   205         formats = defaultdateformats
       
   206     date = date.strip()
       
   207 
       
   208     if date == 'now' or date == _('now'):
       
   209         return makedate()
       
   210     if date == 'today' or date == _('today'):
       
   211         date = datetime.date.today().strftime(r'%b %d')
       
   212         date = encoding.strtolocal(date)
       
   213     elif date == 'yesterday' or date == _('yesterday'):
       
   214         date = (datetime.date.today() -
       
   215                 datetime.timedelta(days=1)).strftime(r'%b %d')
       
   216         date = encoding.strtolocal(date)
       
   217 
       
   218     try:
       
   219         when, offset = map(int, date.split(' '))
       
   220     except ValueError:
       
   221         # fill out defaults
       
   222         now = makedate()
       
   223         defaults = {}
       
   224         for part in ("d", "mb", "yY", "HI", "M", "S"):
       
   225             # this piece is for rounding the specific end of unknowns
       
   226             b = bias.get(part)
       
   227             if b is None:
       
   228                 if part[0:1] in "HMS":
       
   229                     b = "00"
       
   230                 else:
       
   231                     b = "0"
       
   232 
       
   233             # this piece is for matching the generic end to today's date
       
   234             n = datestr(now, "%" + part[0:1])
       
   235 
       
   236             defaults[part] = (b, n)
       
   237 
       
   238         for format in formats:
       
   239             try:
       
   240                 when, offset = strdate(date, format, defaults)
       
   241             except (ValueError, OverflowError):
       
   242                 pass
       
   243             else:
       
   244                 break
       
   245         else:
       
   246             raise error.ParseError(
       
   247                 _('invalid date: %r') % pycompat.bytestr(date))
       
   248     # validate explicit (probably user-specified) date and
       
   249     # time zone offset. values must fit in signed 32 bits for
       
   250     # current 32-bit linux runtimes. timezones go from UTC-12
       
   251     # to UTC+14
       
   252     if when < -0x80000000 or when > 0x7fffffff:
       
   253         raise error.ParseError(_('date exceeds 32 bits: %d') % when)
       
   254     if offset < -50400 or offset > 43200:
       
   255         raise error.ParseError(_('impossible time zone offset: %d') % offset)
       
   256     return when, offset
       
   257 
       
   258 def matchdate(date):
       
   259     """Return a function that matches a given date match specifier
       
   260 
       
   261     Formats include:
       
   262 
       
   263     '{date}' match a given date to the accuracy provided
       
   264 
       
   265     '<{date}' on or before a given date
       
   266 
       
   267     '>{date}' on or after a given date
       
   268 
       
   269     >>> p1 = parsedate(b"10:29:59")
       
   270     >>> p2 = parsedate(b"10:30:00")
       
   271     >>> p3 = parsedate(b"10:30:59")
       
   272     >>> p4 = parsedate(b"10:31:00")
       
   273     >>> p5 = parsedate(b"Sep 15 10:30:00 1999")
       
   274     >>> f = matchdate(b"10:30")
       
   275     >>> f(p1[0])
       
   276     False
       
   277     >>> f(p2[0])
       
   278     True
       
   279     >>> f(p3[0])
       
   280     True
       
   281     >>> f(p4[0])
       
   282     False
       
   283     >>> f(p5[0])
       
   284     False
       
   285     """
       
   286 
       
   287     def lower(date):
       
   288         d = {'mb': "1", 'd': "1"}
       
   289         return parsedate(date, extendeddateformats, d)[0]
       
   290 
       
   291     def upper(date):
       
   292         d = {'mb': "12", 'HI': "23", 'M': "59", 'S': "59"}
       
   293         for days in ("31", "30", "29"):
       
   294             try:
       
   295                 d["d"] = days
       
   296                 return parsedate(date, extendeddateformats, d)[0]
       
   297             except error.ParseError:
       
   298                 pass
       
   299         d["d"] = "28"
       
   300         return parsedate(date, extendeddateformats, d)[0]
       
   301 
       
   302     date = date.strip()
       
   303 
       
   304     if not date:
       
   305         raise error.Abort(_("dates cannot consist entirely of whitespace"))
       
   306     elif date[0] == "<":
       
   307         if not date[1:]:
       
   308             raise error.Abort(_("invalid day spec, use '<DATE'"))
       
   309         when = upper(date[1:])
       
   310         return lambda x: x <= when
       
   311     elif date[0] == ">":
       
   312         if not date[1:]:
       
   313             raise error.Abort(_("invalid day spec, use '>DATE'"))
       
   314         when = lower(date[1:])
       
   315         return lambda x: x >= when
       
   316     elif date[0] == "-":
       
   317         try:
       
   318             days = int(date[1:])
       
   319         except ValueError:
       
   320             raise error.Abort(_("invalid day spec: %s") % date[1:])
       
   321         if days < 0:
       
   322             raise error.Abort(_("%s must be nonnegative (see 'hg help dates')")
       
   323                 % date[1:])
       
   324         when = makedate()[0] - days * 3600 * 24
       
   325         return lambda x: x >= when
       
   326     elif " to " in date:
       
   327         a, b = date.split(" to ")
       
   328         start, stop = lower(a), upper(b)
       
   329         return lambda x: x >= start and x <= stop
       
   330     else:
       
   331         start, stop = lower(date), upper(date)
       
   332         return lambda x: x >= start and x <= stop