comparison sat/tools/common/date_utils.py @ 3905:92482cc80d0b

tools (common/date_utils) handle timestamp and `in` + `delta2human`: regex used to parse datetimes has been improved to handle a unix time (which can be used with `+ <delta>` or `- <delta>`), and the `in <delta>` (e.g. `in 6 days`). `DEFAULT_DATETIME` has been removed as dateutil use current date by default, which is the expected behaviour. Add `delta2human` method which convert the difference of 2 unix times to a human friendly approximate text. rel 372
author Goffi <goffi@goffi.org>
date Thu, 22 Sep 2022 00:01:48 +0200
parents bef32f3ccc06
children 883db2790b11
comparison
equal deleted inserted replaced
3904:0aa7023dcd08 3905:92482cc80d0b
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 """tools to help manipulating time and dates""" 20 """tools to help manipulating time and dates"""
21 21
22 from typing import Union
23 import calendar
24 import datetime
25 import re
26 import time
27
28 from babel import dates
29 from dateutil import parser, tz
30 from dateutil.parser import ParserError
31 from dateutil.relativedelta import relativedelta
32 from dateutil.utils import default_tzinfo
33
34 from sat.core import exceptions
22 from sat.core.constants import Const as C 35 from sat.core.constants import Const as C
23 from sat.core.i18n import _ 36 from sat.core.i18n import _
24 import datetime 37
25 from dateutil import tz, parser 38 RELATIVE_RE = re.compile(
26 from dateutil.relativedelta import relativedelta 39 r"\s*(?P<in>\bin\b)?"
27 from dateutil.utils import default_tzinfo 40 r"(?P<date>[^+-].+[^\s+-])?\s*(?P<direction>[-+])?\s*"
28 from dateutil.parser import ParserError 41 r"\s*(?P<quantity>\d+)\s*"
29 from babel import dates 42 r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d|week|w|year|yr|y))s?"
30 import calendar 43 r"(?P<ago>\s+ago)?\s*",
31 import time 44 re.I
32 import re 45 )
33
34 RELATIVE_RE = re.compile(r"(?P<date>.*?)(?P<direction>[-+]?) *(?P<quantity>\d+) *"
35 r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d"
36 r"|week|w|year|yr|y))s?"
37 r"(?P<ago> +ago)?", re.I)
38 TIME_SYMBOL_MAP = { 46 TIME_SYMBOL_MAP = {
39 "s": "second", 47 "s": "second",
40 "sec": "second", 48 "sec": "second",
41 "m": "minute", 49 "m": "minute",
42 "min": "minute", 50 "min": "minute",
49 "yr": "year", 57 "yr": "year",
50 } 58 }
51 YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+") 59 YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+")
52 TZ_UTC = tz.tzutc() 60 TZ_UTC = tz.tzutc()
53 TZ_LOCAL = tz.gettz() 61 TZ_LOCAL = tz.gettz()
54 # used to replace values when something is missing
55 DEFAULT_DATETIME = datetime.datetime(2000, 0o1, 0o1)
56 62
57 63
58 def date_parse(value, default_tz=TZ_UTC): 64 def date_parse(value, default_tz=TZ_UTC):
59 """Parse a date and return corresponding unix timestamp 65 """Parse a date and return corresponding unix timestamp
60 66
65 value = str(value).strip() 71 value = str(value).strip()
66 dayfirst = False if YEAR_FIRST_RE.match(value) else True 72 dayfirst = False if YEAR_FIRST_RE.match(value) else True
67 73
68 try: 74 try:
69 dt = default_tzinfo( 75 dt = default_tzinfo(
70 parser.parse(value, default=DEFAULT_DATETIME, dayfirst=dayfirst), 76 parser.parse(value, dayfirst=dayfirst),
71 default_tz) 77 default_tz)
72 except ParserError as e: 78 except ParserError as e:
73 if value == "now": 79 if value == "now":
74 dt = datetime.datetime.now(tz.tzutc()) 80 dt = datetime.datetime.now(tz.tzutc())
75 else: 81 else:
76 raise e 82 try:
83 # the date may already be a timestamp
84 return int(value)
85 except ValueError:
86 raise e
77 return calendar.timegm(dt.utctimetuple()) 87 return calendar.timegm(dt.utctimetuple())
78 88
79 def date_parse_ext(value, default_tz=TZ_UTC): 89 def date_parse_ext(value, default_tz=TZ_UTC):
80 """Extended date parse which accept relative date 90 """Extended date parse which accept relative date
81 91
90 """ 100 """
91 m = RELATIVE_RE.match(value) 101 m = RELATIVE_RE.match(value)
92 if m is None: 102 if m is None:
93 return date_parse(value, default_tz=default_tz) 103 return date_parse(value, default_tz=default_tz)
94 104
95 if m.group("direction") and m.group("ago"): 105 if sum(1 for g in ("direction", "in", "ago") if m.group(g)) > 1:
96 raise ValueError( 106 raise ValueError(
97 _("You can't use a direction (+ or -) and \"ago\" at the same time")) 107 _('You can use only one of direction (+ or -), "in" and "ago"'))
98 108
99 if m.group("direction") == '-' or m.group("ago"): 109 if m.group("direction") == '-' or m.group("ago"):
100 direction = -1 110 direction = -1
101 else: 111 else:
102 direction = 1 112 direction = 1
103 113
104 date = m.group("date").strip().lower() 114 date = m.group("date")
115 if date is not None:
116 date = date.strip()
105 if not date or date == "now": 117 if not date or date == "now":
106 dt = datetime.datetime.now(tz.tzutc()) 118 dt = datetime.datetime.now(tz.tzutc())
107 else: 119 else:
108 dt = default_tzinfo(parser.parse(date, dayfirst=True), default_tz) 120 try:
121 dt = default_tzinfo(parser.parse(date, dayfirst=True), default_tz)
122 except ParserError as e:
123 try:
124 timestamp = int(date)
125 except ValueError:
126 raise e
127 else:
128 dt = datetime.datetime.fromtimestamp(timestamp, tz.tzutc())
109 129
110 quantity = int(m.group("quantity")) 130 quantity = int(m.group("quantity"))
111 unit = m.group("unit").lower() 131 unit = m.group("unit").lower()
112 try: 132 try:
113 unit = TIME_SYMBOL_MAP[unit] 133 unit = TIME_SYMBOL_MAP[unit]
189 fmt = "yyyy-MM-ddTHH:mm:ss'Z'" 209 fmt = "yyyy-MM-ddTHH:mm:ss'Z'"
190 return dates.format_datetime(timestamp, format=fmt) 210 return dates.format_datetime(timestamp, format=fmt)
191 else: 211 else:
192 return dates.format_datetime(timestamp, format=fmt, locale=locale_str, 212 return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
193 tzinfo=tz_info) 213 tzinfo=tz_info)
214
215
216 def delta2human(start_ts: Union[float, int], end_ts: Union[float, int]) -> str:
217 """Convert delta of 2 unix times to human readable text
218
219 @param start_ts: timestamp of starting time
220 @param end_ts: timestamp of ending time
221 """
222 if end_ts < start_ts:
223 raise exceptions.InternalError(
224 "end timestamp must be bigger or equal to start timestamp !"
225 )
226 rd = relativedelta(
227 datetime.datetime.fromtimestamp(end_ts),
228 datetime.datetime.fromtimestamp(start_ts)
229 )
230 text_elems = []
231 for unit in ("years", "months", "days", "hours", "minutes"):
232 value = getattr(rd, unit)
233 if value == 1:
234 # we remove final "s" when there is only 1
235 text_elems.append(f"1 {unit[:-1]}")
236 elif value > 1:
237 text_elems.append(f"{value} {unit}")
238
239 return ", ".join(text_elems)