Mercurial > public > mercurial-scm > hg-stable
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 |