Mercurial > public > mercurial-scm > hg-stable
diff mercurial/utils/dateutil.py @ 36636:c6061cadb400
util: extract all date-related utils in utils/dateutil module
With this commit, util.py lose 262 lines
Note for extensions author, if this commit breaks your extension, you can pull
the step-by-step split here to help you more easily pinpoint the renaming that
broke your extension:
hg pull https://bitbucket.org/octobus/mercurial-devel/ -r ac1f6453010d
Differential Revision: https://phab.mercurial-scm.org/D2282
author | Boris Feld <boris.feld@octobus.net> |
---|---|
date | Thu, 15 Feb 2018 17:18:26 +0100 |
parents | mercurial/util.py@281f66777ff0 |
children | d4d2c567bb72 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mercurial/utils/dateutil.py Thu Feb 15 17:18:26 2018 +0100 @@ -0,0 +1,332 @@ +# util.py - Mercurial utility functions relative to dates +# +# Copyright 2018 Boris Feld <boris.feld@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +from __future__ import absolute_import, print_function + +import calendar +import datetime +import time + +from ..i18n import _ +from .. import ( + encoding, + error, + pycompat, +) + +# used by parsedate +defaultdateformats = ( + '%Y-%m-%dT%H:%M:%S', # the 'real' ISO8601 + '%Y-%m-%dT%H:%M', # without seconds + '%Y-%m-%dT%H%M%S', # another awful but legal variant without : + '%Y-%m-%dT%H%M', # without seconds + '%Y-%m-%d %H:%M:%S', # our common legal variant + '%Y-%m-%d %H:%M', # without seconds + '%Y-%m-%d %H%M%S', # without : + '%Y-%m-%d %H%M', # without seconds + '%Y-%m-%d %I:%M:%S%p', + '%Y-%m-%d %H:%M', + '%Y-%m-%d %I:%M%p', + '%Y-%m-%d', + '%m-%d', + '%m/%d', + '%m/%d/%y', + '%m/%d/%Y', + '%a %b %d %H:%M:%S %Y', + '%a %b %d %I:%M:%S%p %Y', + '%a, %d %b %Y %H:%M:%S', # GNU coreutils "/bin/date --rfc-2822" + '%b %d %H:%M:%S %Y', + '%b %d %I:%M:%S%p %Y', + '%b %d %H:%M:%S', + '%b %d %I:%M:%S%p', + '%b %d %H:%M', + '%b %d %I:%M%p', + '%b %d %Y', + '%b %d', + '%H:%M:%S', + '%I:%M:%S%p', + '%H:%M', + '%I:%M%p', +) + +extendeddateformats = defaultdateformats + ( + "%Y", + "%Y-%m", + "%b", + "%b %Y", +) + +def makedate(timestamp=None): + '''Return a unix timestamp (or the current time) as a (unixtime, + offset) tuple based off the local timezone.''' + if timestamp is None: + timestamp = time.time() + if timestamp < 0: + hint = _("check your clock") + raise error.Abort(_("negative timestamp: %d") % timestamp, hint=hint) + delta = (datetime.datetime.utcfromtimestamp(timestamp) - + datetime.datetime.fromtimestamp(timestamp)) + tz = delta.days * 86400 + delta.seconds + return timestamp, tz + +def datestr(date=None, format='%a %b %d %H:%M:%S %Y %1%2'): + """represent a (unixtime, offset) tuple as a localized time. + unixtime is seconds since the epoch, and offset is the time zone's + number of seconds away from UTC. + + >>> datestr((0, 0)) + 'Thu Jan 01 00:00:00 1970 +0000' + >>> datestr((42, 0)) + 'Thu Jan 01 00:00:42 1970 +0000' + >>> datestr((-42, 0)) + 'Wed Dec 31 23:59:18 1969 +0000' + >>> datestr((0x7fffffff, 0)) + 'Tue Jan 19 03:14:07 2038 +0000' + >>> datestr((-0x80000000, 0)) + 'Fri Dec 13 20:45:52 1901 +0000' + """ + t, tz = date or makedate() + if "%1" in format or "%2" in format or "%z" in format: + sign = (tz > 0) and "-" or "+" + minutes = abs(tz) // 60 + q, r = divmod(minutes, 60) + format = format.replace("%z", "%1%2") + format = format.replace("%1", "%c%02d" % (sign, q)) + format = format.replace("%2", "%02d" % r) + d = t - tz + if d > 0x7fffffff: + d = 0x7fffffff + elif d < -0x80000000: + d = -0x80000000 + # Never use time.gmtime() and datetime.datetime.fromtimestamp() + # because they use the gmtime() system call which is buggy on Windows + # for negative values. + t = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=d) + s = encoding.strtolocal(t.strftime(encoding.strfromlocal(format))) + return s + +def shortdate(date=None): + """turn (timestamp, tzoff) tuple into iso 8631 date.""" + return datestr(date, format='%Y-%m-%d') + +def parsetimezone(s): + """find a trailing timezone, if any, in string, and return a + (offset, remainder) pair""" + s = pycompat.bytestr(s) + + if s.endswith("GMT") or s.endswith("UTC"): + return 0, s[:-3].rstrip() + + # Unix-style timezones [+-]hhmm + if len(s) >= 5 and s[-5] in "+-" and s[-4:].isdigit(): + sign = (s[-5] == "+") and 1 or -1 + hours = int(s[-4:-2]) + minutes = int(s[-2:]) + return -sign * (hours * 60 + minutes) * 60, s[:-5].rstrip() + + # ISO8601 trailing Z + if s.endswith("Z") and s[-2:-1].isdigit(): + return 0, s[:-1] + + # ISO8601-style [+-]hh:mm + if (len(s) >= 6 and s[-6] in "+-" and s[-3] == ":" and + s[-5:-3].isdigit() and s[-2:].isdigit()): + sign = (s[-6] == "+") and 1 or -1 + hours = int(s[-5:-3]) + minutes = int(s[-2:]) + return -sign * (hours * 60 + minutes) * 60, s[:-6] + + return None, s + +def strdate(string, format, defaults=None): + """parse a localized time string and return a (unixtime, offset) tuple. + if the string cannot be parsed, ValueError is raised.""" + if defaults is None: + defaults = {} + + # NOTE: unixtime = localunixtime + offset + offset, date = parsetimezone(string) + + # add missing elements from defaults + usenow = False # default to using biased defaults + for part in ("S", "M", "HI", "d", "mb", "yY"): # decreasing specificity + part = pycompat.bytestr(part) + found = [True for p in part if ("%"+p) in format] + if not found: + date += "@" + defaults[part][usenow] + format += "@%" + part[0] + else: + # We've found a specific time element, less specific time + # elements are relative to today + usenow = True + + timetuple = time.strptime(encoding.strfromlocal(date), + encoding.strfromlocal(format)) + localunixtime = int(calendar.timegm(timetuple)) + if offset is None: + # local timezone + unixtime = int(time.mktime(timetuple)) + offset = unixtime - localunixtime + else: + unixtime = localunixtime + offset + return unixtime, offset + +def parsedate(date, formats=None, bias=None): + """parse a localized date/time and return a (unixtime, offset) tuple. + + The date may be a "unixtime offset" string or in one of the specified + formats. If the date already is a (unixtime, offset) tuple, it is returned. + + >>> parsedate(b' today ') == parsedate( + ... datetime.date.today().strftime('%b %d').encode('ascii')) + True + >>> parsedate(b'yesterday ') == parsedate( + ... (datetime.date.today() - datetime.timedelta(days=1) + ... ).strftime('%b %d').encode('ascii')) + True + >>> now, tz = makedate() + >>> strnow, strtz = parsedate(b'now') + >>> (strnow - now) < 1 + True + >>> tz == strtz + True + """ + if bias is None: + bias = {} + if not date: + return 0, 0 + if isinstance(date, tuple) and len(date) == 2: + return date + if not formats: + formats = defaultdateformats + date = date.strip() + + if date == 'now' or date == _('now'): + return makedate() + if date == 'today' or date == _('today'): + date = datetime.date.today().strftime(r'%b %d') + date = encoding.strtolocal(date) + elif date == 'yesterday' or date == _('yesterday'): + date = (datetime.date.today() - + datetime.timedelta(days=1)).strftime(r'%b %d') + date = encoding.strtolocal(date) + + try: + when, offset = map(int, date.split(' ')) + except ValueError: + # fill out defaults + now = makedate() + defaults = {} + for part in ("d", "mb", "yY", "HI", "M", "S"): + # this piece is for rounding the specific end of unknowns + b = bias.get(part) + if b is None: + if part[0:1] in "HMS": + b = "00" + else: + b = "0" + + # this piece is for matching the generic end to today's date + n = datestr(now, "%" + part[0:1]) + + defaults[part] = (b, n) + + for format in formats: + try: + when, offset = strdate(date, format, defaults) + except (ValueError, OverflowError): + pass + else: + break + else: + raise error.ParseError( + _('invalid date: %r') % pycompat.bytestr(date)) + # validate explicit (probably user-specified) date and + # time zone offset. values must fit in signed 32 bits for + # current 32-bit linux runtimes. timezones go from UTC-12 + # to UTC+14 + if when < -0x80000000 or when > 0x7fffffff: + raise error.ParseError(_('date exceeds 32 bits: %d') % when) + if offset < -50400 or offset > 43200: + raise error.ParseError(_('impossible time zone offset: %d') % offset) + return when, offset + +def matchdate(date): + """Return a function that matches a given date match specifier + + Formats include: + + '{date}' match a given date to the accuracy provided + + '<{date}' on or before a given date + + '>{date}' on or after a given date + + >>> p1 = parsedate(b"10:29:59") + >>> p2 = parsedate(b"10:30:00") + >>> p3 = parsedate(b"10:30:59") + >>> p4 = parsedate(b"10:31:00") + >>> p5 = parsedate(b"Sep 15 10:30:00 1999") + >>> f = matchdate(b"10:30") + >>> f(p1[0]) + False + >>> f(p2[0]) + True + >>> f(p3[0]) + True + >>> f(p4[0]) + False + >>> f(p5[0]) + False + """ + + def lower(date): + d = {'mb': "1", 'd': "1"} + return parsedate(date, extendeddateformats, d)[0] + + def upper(date): + d = {'mb': "12", 'HI': "23", 'M': "59", 'S': "59"} + for days in ("31", "30", "29"): + try: + d["d"] = days + return parsedate(date, extendeddateformats, d)[0] + except error.ParseError: + pass + d["d"] = "28" + return parsedate(date, extendeddateformats, d)[0] + + date = date.strip() + + if not date: + raise error.Abort(_("dates cannot consist entirely of whitespace")) + elif date[0] == "<": + if not date[1:]: + raise error.Abort(_("invalid day spec, use '<DATE'")) + when = upper(date[1:]) + return lambda x: x <= when + elif date[0] == ">": + if not date[1:]: + raise error.Abort(_("invalid day spec, use '>DATE'")) + when = lower(date[1:]) + return lambda x: x >= when + elif date[0] == "-": + try: + days = int(date[1:]) + except ValueError: + raise error.Abort(_("invalid day spec: %s") % date[1:]) + if days < 0: + raise error.Abort(_("%s must be nonnegative (see 'hg help dates')") + % date[1:]) + when = makedate()[0] - days * 3600 * 24 + return lambda x: x >= when + elif " to " in date: + a, b = date.split(" to ") + start, stop = lower(a), upper(b) + return lambda x: x >= start and x <= stop + else: + start, stop = lower(date), upper(date) + return lambda x: x >= start and x <= stop