comparison libervia/backend/tools/common/date_utils.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/tools/common/date_utils.py@432f7e422a27
children 4325a0f13b0f
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3
4 # SAT: a jabber client
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
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/>.
19
20 """tools to help manipulating time and dates"""
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 libervia.backend.core import exceptions
35 from libervia.backend.core.constants import Const as C
36 from libervia.backend.core.i18n import _
37
38 RELATIVE_RE = re.compile(
39 r"\s*(?P<in>\bin\b)?"
40 r"(?P<date>[^+-].+[^\s+-])?\s*(?P<direction>[-+])?\s*"
41 r"\s*(?P<quantity>\d+)\s*"
42 r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d|week|w|year|yr|y))s?"
43 r"(?P<ago>\s+ago)?\s*",
44 re.I
45 )
46 TIME_SYMBOL_MAP = {
47 "s": "second",
48 "sec": "second",
49 "m": "minute",
50 "min": "minute",
51 "h": "hour",
52 "hr": "hour",
53 "d": "day",
54 "w": "week",
55 "mo": "month",
56 "y": "year",
57 "yr": "year",
58 }
59 YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+")
60 TZ_UTC = tz.tzutc()
61 TZ_LOCAL = tz.gettz()
62
63
64 def date_parse(value, default_tz=TZ_UTC):
65 """Parse a date and return corresponding unix timestamp
66
67 @param value(unicode): date to parse, in any format supported by parser
68 @param default_tz(datetime.tzinfo): default timezone
69 @return (int): timestamp
70 """
71 value = str(value).strip()
72 dayfirst = False if YEAR_FIRST_RE.match(value) else True
73
74 try:
75 dt = default_tzinfo(
76 parser.parse(value, dayfirst=dayfirst),
77 default_tz)
78 except ParserError as e:
79 if value == "now":
80 dt = datetime.datetime.now(tz.tzutc())
81 else:
82 try:
83 # the date may already be a timestamp
84 return int(value)
85 except ValueError:
86 raise e
87 return calendar.timegm(dt.utctimetuple())
88
89 def date_parse_ext(value, default_tz=TZ_UTC):
90 """Extended date parse which accept relative date
91
92 @param value(unicode): date to parse, in any format supported by parser
93 and with the hability to specify X days/weeks/months/years in the past or future.
94 Relative date are specified either with something like `[main_date] +1 week`
95 or with something like `3 days ago`, and it is case insensitive. [main_date] is
96 a date parsable by parser, or empty to specify current date/time.
97 "now" can also be used to specify current date/time.
98 @param default_tz(datetime.tzinfo): same as for date_parse
99 @return (int): timestamp
100 """
101 m = RELATIVE_RE.match(value)
102 if m is None:
103 return date_parse(value, default_tz=default_tz)
104
105 if sum(1 for g in ("direction", "in", "ago") if m.group(g)) > 1:
106 raise ValueError(
107 _('You can use only one of direction (+ or -), "in" and "ago"'))
108
109 if m.group("direction") == '-' or m.group("ago"):
110 direction = -1
111 else:
112 direction = 1
113
114 date = m.group("date")
115 if date is not None:
116 date = date.strip()
117 if not date or date == "now":
118 dt = datetime.datetime.now(tz.tzutc())
119 else:
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())
129
130 quantity = int(m.group("quantity"))
131 unit = m.group("unit").lower()
132 try:
133 unit = TIME_SYMBOL_MAP[unit]
134 except KeyError:
135 pass
136 delta_kw = {f"{unit}s": direction * quantity}
137 dt = dt + relativedelta(**delta_kw)
138 return calendar.timegm(dt.utctimetuple())
139
140
141 def date_fmt(
142 timestamp: Union[float, int, str],
143 fmt: str = "short",
144 date_only: bool = False,
145 auto_limit: int = 7,
146 auto_old_fmt: str = "short",
147 auto_new_fmt: str = "relative",
148 locale_str: str = C.DEFAULT_LOCALE,
149 tz_info: Union[datetime.tzinfo, str] = TZ_UTC
150 ) -> str:
151 """Format date according to locale
152
153 @param timestamp: unix time
154 @param fmt: one of:
155 - short: e.g. u'31/12/17'
156 - medium: e.g. u'Apr 1, 2007'
157 - long: e.g. u'April 1, 2007'
158 - full: e.g. u'Sunday, April 1, 2007'
159 - relative: format in relative time
160 e.g.: 3 hours
161 note that this format is not precise
162 - iso: ISO 8601 format
163 e.g.: u'2007-04-01T19:53:23Z'
164 - auto: use auto_old_fmt if date is older than auto_limit
165 else use auto_new_fmt
166 - auto_day: shorcut to set auto format with change on day
167 old format will be short, and new format will be time only
168 or a free value which is passed to babel.dates.format_datetime
169 (see http://babel.pocoo.org/en/latest/dates.html?highlight=pattern#pattern-syntax)
170 @param date_only: if True, only display date (not datetime)
171 @param auto_limit: limit in days before using auto_old_fmt
172 use 0 to have a limit at last midnight (day change)
173 @param auto_old_fmt: format to use when date is older than limit
174 @param auto_new_fmt: format to use when date is equal to or more recent
175 than limit
176 @param locale_str: locale to use (as understood by babel)
177 @param tz_info: time zone to use
178
179 """
180 timestamp = float(timestamp)
181 if isinstance(tz_info, str):
182 tz_info = tz.gettz(tz_info)
183 if fmt == "auto_day":
184 fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
185 if fmt == "auto":
186 if auto_limit == 0:
187 now = datetime.datetime.now(tz_info)
188 # we want to use given tz_info, so we don't use date() or today()
189 today = datetime.datetime(year=now.year, month=now.month, day=now.day,
190 tzinfo=now.tzinfo)
191 today = calendar.timegm(today.utctimetuple())
192 if timestamp < today:
193 fmt = auto_old_fmt
194 else:
195 fmt = auto_new_fmt
196 else:
197 days_delta = (time.time() - timestamp) / 3600
198 if days_delta > (auto_limit or 7):
199 fmt = auto_old_fmt
200 else:
201 fmt = auto_new_fmt
202
203 if fmt == "relative":
204 delta = timestamp - time.time()
205 return dates.format_timedelta(
206 delta, granularity="minute", add_direction=True, locale=locale_str
207 )
208 elif fmt in ("short", "long", "full"):
209 if date_only:
210 dt = datetime.datetime.fromtimestamp(timestamp, tz_info)
211 return dates.format_date(dt, format=fmt, locale=locale_str)
212 else:
213 return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
214 tzinfo=tz_info)
215 elif fmt == "iso":
216 if date_only:
217 fmt = "yyyy-MM-dd"
218 else:
219 fmt = "yyyy-MM-ddTHH:mm:ss'Z'"
220 return dates.format_datetime(timestamp, format=fmt)
221 else:
222 return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
223 tzinfo=tz_info)
224
225
226 def delta2human(start_ts: Union[float, int], end_ts: Union[float, int]) -> str:
227 """Convert delta of 2 unix times to human readable text
228
229 @param start_ts: timestamp of starting time
230 @param end_ts: timestamp of ending time
231 """
232 if end_ts < start_ts:
233 raise exceptions.InternalError(
234 "end timestamp must be bigger or equal to start timestamp !"
235 )
236 rd = relativedelta(
237 datetime.datetime.fromtimestamp(end_ts),
238 datetime.datetime.fromtimestamp(start_ts)
239 )
240 text_elems = []
241 for unit in ("years", "months", "days", "hours", "minutes"):
242 value = getattr(rd, unit)
243 if value == 1:
244 # we remove final "s" when there is only 1
245 text_elems.append(f"1 {unit[:-1]}")
246 elif value > 1:
247 text_elems.append(f"{value} {unit}")
248
249 return ", ".join(text_elems)
250
251
252 def get_timezone_name(tzinfo, timestamp: Union[float, int]) -> str:
253 """
254 Get the DST-aware timezone name for a given timezone and timestamp.
255
256 @param tzinfo: The timezone to get the name for
257 @param timestamp: The timestamp to use, as a Unix timestamp (number of seconds since
258 the Unix epoch).
259 @return: The DST-aware timezone name.
260 """
261
262 dt = datetime.datetime.fromtimestamp(timestamp)
263 dt_tz = dt.replace(tzinfo=tzinfo)
264 tz_name = dt_tz.tzname()
265 if tz_name is None:
266 raise exceptions.InternalError("tz_name should not be None")
267 return tz_name