comparison 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
comparison
equal deleted inserted replaced
36635:4de15c54e59f 36636: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