|
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 |