dateutil.py
432 lines
| 12.5 KiB
| text/x-python
|
PythonLexer
Boris Feld
|
r36625 | # 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. | ||||
Matt Harbison
|
r52756 | from __future__ import annotations | ||
Boris Feld
|
r36625 | |||
import calendar | ||||
import datetime | ||||
import time | ||||
r52178 | from typing import ( | |||
Callable, | ||||
Dict, | ||||
Iterable, | ||||
Optional, | ||||
Tuple, | ||||
Union, | ||||
) | ||||
Boris Feld
|
r36625 | from ..i18n import _ | ||
from .. import ( | ||||
encoding, | ||||
error, | ||||
pycompat, | ||||
) | ||||
r52178 | # keeps pyflakes happy | |||
assert [ | ||||
Callable, | ||||
Dict, | ||||
Iterable, | ||||
Optional, | ||||
Tuple, | ||||
Union, | ||||
] | ||||
Matt Harbison
|
r47387 | |||
r52178 | hgdate = Tuple[float, int] # (unixtime, offset) | |||
Matt Harbison
|
r47387 | |||
Boris Feld
|
r36625 | # used by parsedate | ||
defaultdateformats = ( | ||||
Augie Fackler
|
r43347 | b'%Y-%m-%dT%H:%M:%S', # the 'real' ISO8601 | ||
b'%Y-%m-%dT%H:%M', # without seconds | ||||
b'%Y-%m-%dT%H%M%S', # another awful but legal variant without : | ||||
b'%Y-%m-%dT%H%M', # without seconds | ||||
b'%Y-%m-%d %H:%M:%S', # our common legal variant | ||||
b'%Y-%m-%d %H:%M', # without seconds | ||||
b'%Y-%m-%d %H%M%S', # without : | ||||
b'%Y-%m-%d %H%M', # without seconds | ||||
b'%Y-%m-%d %I:%M:%S%p', | ||||
b'%Y-%m-%d %H:%M', | ||||
b'%Y-%m-%d %I:%M%p', | ||||
b'%Y-%m-%d', | ||||
b'%m-%d', | ||||
b'%m/%d', | ||||
b'%m/%d/%y', | ||||
b'%m/%d/%Y', | ||||
b'%a %b %d %H:%M:%S %Y', | ||||
b'%a %b %d %I:%M:%S%p %Y', | ||||
b'%a, %d %b %Y %H:%M:%S', # GNU coreutils "/bin/date --rfc-2822" | ||||
b'%b %d %H:%M:%S %Y', | ||||
b'%b %d %I:%M:%S%p %Y', | ||||
b'%b %d %H:%M:%S', | ||||
b'%b %d %I:%M:%S%p', | ||||
b'%b %d %H:%M', | ||||
b'%b %d %I:%M%p', | ||||
b'%b %d %Y', | ||||
b'%b %d', | ||||
b'%H:%M:%S', | ||||
b'%I:%M:%S%p', | ||||
b'%H:%M', | ||||
b'%I:%M%p', | ||||
Boris Feld
|
r36625 | ) | ||
Augie Fackler
|
r46554 | extendeddateformats = defaultdateformats + ( | ||
b"%Y", | ||||
b"%Y-%m", | ||||
b"%b", | ||||
b"%b %Y", | ||||
) | ||||
Augie Fackler
|
r43346 | |||
Boris Feld
|
r36625 | |||
r52180 | def makedate(timestamp: Optional[float] = None) -> hgdate: | |||
Augie Fackler
|
r46554 | """Return a unix timestamp (or the current time) as a (unixtime, | ||
Mads Kiilerich
|
r52636 | offset) tuple based off the local timezone. | ||
>>> import os, time | ||||
>>> os.environ['TZ'] = 'Asia/Novokuznetsk' | ||||
>>> time.tzset() | ||||
>>> def dtu(*a): | ||||
... return datetime.datetime(*a, tzinfo=datetime.timezone.utc) | ||||
# Old winter timezone, +7 | ||||
>>> makedate(dtu(2010, 1, 1, 5, 0, 0).timestamp()) | ||||
(1262322000.0, -25200) | ||||
# Same timezone in summer, +7, so no DST | ||||
>>> makedate(dtu(2010, 7, 1, 5, 0, 0).timestamp()) | ||||
(1277960400.0, -25200) | ||||
# Changing to new winter timezone, from +7 to +6 (ae04af1ce78d testcase) | ||||
>>> makedate(dtu(2010, 10, 30, 20, 0, 0).timestamp() - 1) | ||||
(1288468799.0, -25200) | ||||
>>> makedate(dtu(2010, 10, 30, 20, 0, 0).timestamp()) | ||||
(1288468800.0, -21600) | ||||
>>> makedate(dtu(2011, 1, 1, 5, 0, 0).timestamp()) | ||||
(1293858000.0, -21600) | ||||
# Introducing DST, changing +6 to +7 | ||||
>>> makedate(dtu(2011, 3, 26, 20, 0, 0).timestamp() - 1) | ||||
(1301169599.0, -21600) | ||||
>>> makedate(dtu(2011, 3, 26, 20, 0, 0).timestamp()) | ||||
(1301169600.0, -25200) | ||||
""" | ||||
Boris Feld
|
r36625 | if timestamp is None: | ||
timestamp = time.time() | ||||
if timestamp < 0: | ||||
Augie Fackler
|
r43347 | hint = _(b"check your clock") | ||
Martin von Zweigbergk
|
r47147 | raise error.InputError( | ||
_(b"negative timestamp: %d") % timestamp, hint=hint | ||||
) | ||||
Mads Kiilerich
|
r51645 | tz = round( | ||
Augie Fackler
|
r43346 | timestamp | ||
Mads Kiilerich
|
r51645 | - datetime.datetime.fromtimestamp( | ||
timestamp, | ||||
) | ||||
.replace(tzinfo=datetime.timezone.utc) | ||||
.timestamp() | ||||
) | ||||
Boris Feld
|
r36625 | return timestamp, tz | ||
Augie Fackler
|
r43346 | |||
r52180 | def datestr( | |||
date: Optional[hgdate] = None, | ||||
format: bytes = b'%a %b %d %H:%M:%S %Y %1%2', | ||||
) -> bytes: | ||||
Boris Feld
|
r36625 | """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() | ||||
Augie Fackler
|
r43347 | if b"%1" in format or b"%2" in format or b"%z" in format: | ||
sign = (tz > 0) and b"-" or b"+" | ||||
Boris Feld
|
r36625 | minutes = abs(tz) // 60 | ||
q, r = divmod(minutes, 60) | ||||
Augie Fackler
|
r43347 | format = format.replace(b"%z", b"%1%2") | ||
format = format.replace(b"%1", b"%c%02d" % (sign, q)) | ||||
format = format.replace(b"%2", b"%02d" % r) | ||||
Boris Feld
|
r36625 | d = t - tz | ||
Augie Fackler
|
r43346 | if d > 0x7FFFFFFF: | ||
d = 0x7FFFFFFF | ||||
Boris Feld
|
r36625 | 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 | ||||
Augie Fackler
|
r43346 | |||
r52180 | def shortdate(date: Optional[hgdate] = None) -> bytes: | |||
Boris Feld
|
r36625 | """turn (timestamp, tzoff) tuple into iso 8631 date.""" | ||
Augie Fackler
|
r43347 | return datestr(date, format=b'%Y-%m-%d') | ||
Boris Feld
|
r36625 | |||
Augie Fackler
|
r43346 | |||
r52180 | def parsetimezone(s: bytes) -> Tuple[Optional[int], bytes]: | |||
Boris Feld
|
r36625 | """find a trailing timezone, if any, in string, and return a | ||
Augie Fackler
|
r46554 | (offset, remainder) pair""" | ||
Boris Feld
|
r36625 | s = pycompat.bytestr(s) | ||
Augie Fackler
|
r43347 | if s.endswith(b"GMT") or s.endswith(b"UTC"): | ||
Boris Feld
|
r36625 | return 0, s[:-3].rstrip() | ||
# Unix-style timezones [+-]hhmm | ||||
Augie Fackler
|
r43347 | if len(s) >= 5 and s[-5] in b"+-" and s[-4:].isdigit(): | ||
sign = (s[-5] == b"+") and 1 or -1 | ||||
Boris Feld
|
r36625 | hours = int(s[-4:-2]) | ||
minutes = int(s[-2:]) | ||||
return -sign * (hours * 60 + minutes) * 60, s[:-5].rstrip() | ||||
# ISO8601 trailing Z | ||||
Augie Fackler
|
r43347 | if s.endswith(b"Z") and s[-2:-1].isdigit(): | ||
Boris Feld
|
r36625 | return 0, s[:-1] | ||
# ISO8601-style [+-]hh:mm | ||||
Augie Fackler
|
r43346 | if ( | ||
len(s) >= 6 | ||||
Augie Fackler
|
r43347 | and s[-6] in b"+-" | ||
and s[-3] == b":" | ||||
Augie Fackler
|
r43346 | and s[-5:-3].isdigit() | ||
and s[-2:].isdigit() | ||||
): | ||||
Augie Fackler
|
r43347 | sign = (s[-6] == b"+") and 1 or -1 | ||
Boris Feld
|
r36625 | hours = int(s[-5:-3]) | ||
minutes = int(s[-2:]) | ||||
return -sign * (hours * 60 + minutes) * 60, s[:-6] | ||||
return None, s | ||||
Augie Fackler
|
r43346 | |||
r52180 | def strdate( | |||
string: bytes, | ||||
format: bytes, | ||||
defaults: Optional[Dict[bytes, Tuple[bytes, bytes]]] = None, | ||||
) -> hgdate: | ||||
Boris Feld
|
r36625 | """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 | ||||
Augie Fackler
|
r43346 | usenow = False # default to using biased defaults | ||
Augie Fackler
|
r43347 | for part in ( | ||
b"S", | ||||
b"M", | ||||
b"HI", | ||||
b"d", | ||||
b"mb", | ||||
b"yY", | ||||
): # decreasing specificity | ||||
Boris Feld
|
r36625 | part = pycompat.bytestr(part) | ||
Augie Fackler
|
r43347 | found = [True for p in part if (b"%" + p) in format] | ||
Boris Feld
|
r36625 | if not found: | ||
Augie Fackler
|
r43347 | date += b"@" + defaults[part][usenow] | ||
format += b"@%" + part[0] | ||||
Boris Feld
|
r36625 | else: | ||
# We've found a specific time element, less specific time | ||||
# elements are relative to today | ||||
usenow = True | ||||
Augie Fackler
|
r43346 | timetuple = time.strptime( | ||
encoding.strfromlocal(date), encoding.strfromlocal(format) | ||||
) | ||||
Boris Feld
|
r36625 | 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 | ||||
Augie Fackler
|
r43346 | |||
r52180 | def parsedate( | |||
date: Union[bytes, hgdate], | ||||
formats: Optional[Iterable[bytes]] = None, | ||||
bias: Optional[Dict[bytes, bytes]] = None, | ||||
) -> hgdate: | ||||
Boris Feld
|
r36625 | """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 | ||||
Jun Wu
|
r44220 | >>> parsedate(b'2000 UTC', formats=extendeddateformats) | ||
(946684800, 0) | ||||
Boris Feld
|
r36625 | """ | ||
if bias is None: | ||||
bias = {} | ||||
if not date: | ||||
return 0, 0 | ||||
Matt Harbison
|
r47387 | if isinstance(date, tuple): | ||
if len(date) == 2: | ||||
return date | ||||
else: | ||||
raise error.ProgrammingError(b"invalid date format") | ||||
Boris Feld
|
r36625 | if not formats: | ||
formats = defaultdateformats | ||||
date = date.strip() | ||||
Augie Fackler
|
r43347 | if date == b'now' or date == _(b'now'): | ||
Boris Feld
|
r36625 | return makedate() | ||
Augie Fackler
|
r43347 | if date == b'today' or date == _(b'today'): | ||
Augie Fackler
|
r43906 | date = datetime.date.today().strftime('%b %d') | ||
Boris Feld
|
r36625 | date = encoding.strtolocal(date) | ||
Augie Fackler
|
r43347 | elif date == b'yesterday' or date == _(b'yesterday'): | ||
Augie Fackler
|
r43346 | date = (datetime.date.today() - datetime.timedelta(days=1)).strftime( | ||
r'%b %d' | ||||
) | ||||
Boris Feld
|
r36625 | date = encoding.strtolocal(date) | ||
try: | ||||
Augie Fackler
|
r43347 | when, offset = map(int, date.split(b' ')) | ||
Boris Feld
|
r36625 | except ValueError: | ||
# fill out defaults | ||||
now = makedate() | ||||
defaults = {} | ||||
Augie Fackler
|
r43347 | for part in (b"d", b"mb", b"yY", b"HI", b"M", b"S"): | ||
Boris Feld
|
r36625 | # this piece is for rounding the specific end of unknowns | ||
b = bias.get(part) | ||||
if b is None: | ||||
Augie Fackler
|
r43347 | if part[0:1] in b"HMS": | ||
b = b"00" | ||||
Boris Feld
|
r36625 | else: | ||
Jun Wu
|
r44220 | # year, month, and day start from 1 | ||
b = b"1" | ||||
Boris Feld
|
r36625 | |||
# this piece is for matching the generic end to today's date | ||||
Augie Fackler
|
r43347 | n = datestr(now, b"%" + part[0:1]) | ||
Boris Feld
|
r36625 | |||
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( | ||||
Augie Fackler
|
r43347 | _(b'invalid date: %r') % pycompat.bytestr(date) | ||
Augie Fackler
|
r43346 | ) | ||
Boris Feld
|
r36625 | # 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 | ||||
Augie Fackler
|
r43346 | if when < -0x80000000 or when > 0x7FFFFFFF: | ||
Augie Fackler
|
r43347 | raise error.ParseError(_(b'date exceeds 32 bits: %d') % when) | ||
Boris Feld
|
r36625 | if offset < -50400 or offset > 43200: | ||
Augie Fackler
|
r43347 | raise error.ParseError(_(b'impossible time zone offset: %d') % offset) | ||
Boris Feld
|
r36625 | return when, offset | ||
Augie Fackler
|
r43346 | |||
r52180 | def matchdate(date: bytes) -> Callable[[float], bool]: | |||
Boris Feld
|
r36625 | """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 | ||||
""" | ||||
r52180 | def lower(date: bytes) -> float: | |||
Augie Fackler
|
r43347 | d = {b'mb': b"1", b'd': b"1"} | ||
Boris Feld
|
r36625 | return parsedate(date, extendeddateformats, d)[0] | ||
r52180 | def upper(date: bytes) -> float: | |||
Augie Fackler
|
r43347 | d = {b'mb': b"12", b'HI': b"23", b'M': b"59", b'S': b"59"} | ||
for days in (b"31", b"30", b"29"): | ||||
Boris Feld
|
r36625 | try: | ||
Augie Fackler
|
r43347 | d[b"d"] = days | ||
Boris Feld
|
r36625 | return parsedate(date, extendeddateformats, d)[0] | ||
except error.ParseError: | ||||
pass | ||||
Augie Fackler
|
r43347 | d[b"d"] = b"28" | ||
Boris Feld
|
r36625 | return parsedate(date, extendeddateformats, d)[0] | ||
date = date.strip() | ||||
if not date: | ||||
Martin von Zweigbergk
|
r47147 | raise error.InputError( | ||
_(b"dates cannot consist entirely of whitespace") | ||||
) | ||||
Mark Thomas
|
r40292 | elif date[0:1] == b"<": | ||
Boris Feld
|
r36625 | if not date[1:]: | ||
Martin von Zweigbergk
|
r47147 | raise error.InputError(_(b"invalid day spec, use '<DATE'")) | ||
Boris Feld
|
r36625 | when = upper(date[1:]) | ||
return lambda x: x <= when | ||||
Mark Thomas
|
r40292 | elif date[0:1] == b">": | ||
Boris Feld
|
r36625 | if not date[1:]: | ||
Martin von Zweigbergk
|
r47147 | raise error.InputError(_(b"invalid day spec, use '>DATE'")) | ||
Boris Feld
|
r36625 | when = lower(date[1:]) | ||
return lambda x: x >= when | ||||
Mark Thomas
|
r40292 | elif date[0:1] == b"-": | ||
Boris Feld
|
r36625 | try: | ||
days = int(date[1:]) | ||||
except ValueError: | ||||
Martin von Zweigbergk
|
r47147 | raise error.InputError(_(b"invalid day spec: %s") % date[1:]) | ||
Boris Feld
|
r36625 | if days < 0: | ||
Martin von Zweigbergk
|
r47147 | raise error.InputError( | ||
Augie Fackler
|
r43347 | _(b"%s must be nonnegative (see 'hg help dates')") % date[1:] | ||
Augie Fackler
|
r43346 | ) | ||
Boris Feld
|
r36625 | when = makedate()[0] - days * 3600 * 24 | ||
return lambda x: x >= when | ||||
Mark Thomas
|
r40292 | elif b" to " in date: | ||
a, b = date.split(b" to ") | ||||
Boris Feld
|
r36625 | 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 | ||||