Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/tools/common/date_utils.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 + + +# SAT: a jabber client +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""tools to help manipulating time and dates""" + +from typing import Union +import calendar +import datetime +import re +import time + +from babel import dates +from dateutil import parser, tz +from dateutil.parser import ParserError +from dateutil.relativedelta import relativedelta +from dateutil.utils import default_tzinfo + +from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.i18n import _ + +RELATIVE_RE = re.compile( + r"\s*(?P<in>\bin\b)?" + r"(?P<date>[^+-].+[^\s+-])?\s*(?P<direction>[-+])?\s*" + r"\s*(?P<quantity>\d+)\s*" + r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d|week|w|year|yr|y))s?" + r"(?P<ago>\s+ago)?\s*", + re.I +) +TIME_SYMBOL_MAP = { + "s": "second", + "sec": "second", + "m": "minute", + "min": "minute", + "h": "hour", + "hr": "hour", + "d": "day", + "w": "week", + "mo": "month", + "y": "year", + "yr": "year", +} +YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+") +TZ_UTC = tz.tzutc() +TZ_LOCAL = tz.gettz() + + +def date_parse(value, default_tz=TZ_UTC): + """Parse a date and return corresponding unix timestamp + + @param value(unicode): date to parse, in any format supported by parser + @param default_tz(datetime.tzinfo): default timezone + @return (int): timestamp + """ + value = str(value).strip() + dayfirst = False if YEAR_FIRST_RE.match(value) else True + + try: + dt = default_tzinfo( + parser.parse(value, dayfirst=dayfirst), + default_tz) + except ParserError as e: + if value == "now": + dt = datetime.datetime.now(tz.tzutc()) + else: + try: + # the date may already be a timestamp + return int(value) + except ValueError: + raise e + return calendar.timegm(dt.utctimetuple()) + +def date_parse_ext(value, default_tz=TZ_UTC): + """Extended date parse which accept relative date + + @param value(unicode): date to parse, in any format supported by parser + and with the hability to specify X days/weeks/months/years in the past or future. + Relative date are specified either with something like `[main_date] +1 week` + or with something like `3 days ago`, and it is case insensitive. [main_date] is + a date parsable by parser, or empty to specify current date/time. + "now" can also be used to specify current date/time. + @param default_tz(datetime.tzinfo): same as for date_parse + @return (int): timestamp + """ + m = RELATIVE_RE.match(value) + if m is None: + return date_parse(value, default_tz=default_tz) + + if sum(1 for g in ("direction", "in", "ago") if m.group(g)) > 1: + raise ValueError( + _('You can use only one of direction (+ or -), "in" and "ago"')) + + if m.group("direction") == '-' or m.group("ago"): + direction = -1 + else: + direction = 1 + + date = m.group("date") + if date is not None: + date = date.strip() + if not date or date == "now": + dt = datetime.datetime.now(tz.tzutc()) + else: + try: + dt = default_tzinfo(parser.parse(date, dayfirst=True), default_tz) + except ParserError as e: + try: + timestamp = int(date) + except ValueError: + raise e + else: + dt = datetime.datetime.fromtimestamp(timestamp, tz.tzutc()) + + quantity = int(m.group("quantity")) + unit = m.group("unit").lower() + try: + unit = TIME_SYMBOL_MAP[unit] + except KeyError: + pass + delta_kw = {f"{unit}s": direction * quantity} + dt = dt + relativedelta(**delta_kw) + return calendar.timegm(dt.utctimetuple()) + + +def date_fmt( + timestamp: Union[float, int, str], + fmt: str = "short", + date_only: bool = False, + auto_limit: int = 7, + auto_old_fmt: str = "short", + auto_new_fmt: str = "relative", + locale_str: str = C.DEFAULT_LOCALE, + tz_info: Union[datetime.tzinfo, str] = TZ_UTC +) -> str: + """Format date according to locale + + @param timestamp: unix time + @param fmt: one of: + - short: e.g. u'31/12/17' + - medium: e.g. u'Apr 1, 2007' + - long: e.g. u'April 1, 2007' + - full: e.g. u'Sunday, April 1, 2007' + - relative: format in relative time + e.g.: 3 hours + note that this format is not precise + - iso: ISO 8601 format + e.g.: u'2007-04-01T19:53:23Z' + - auto: use auto_old_fmt if date is older than auto_limit + else use auto_new_fmt + - auto_day: shorcut to set auto format with change on day + old format will be short, and new format will be time only + or a free value which is passed to babel.dates.format_datetime + (see http://babel.pocoo.org/en/latest/dates.html?highlight=pattern#pattern-syntax) + @param date_only: if True, only display date (not datetime) + @param auto_limit: limit in days before using auto_old_fmt + use 0 to have a limit at last midnight (day change) + @param auto_old_fmt: format to use when date is older than limit + @param auto_new_fmt: format to use when date is equal to or more recent + than limit + @param locale_str: locale to use (as understood by babel) + @param tz_info: time zone to use + + """ + timestamp = float(timestamp) + if isinstance(tz_info, str): + tz_info = tz.gettz(tz_info) + if fmt == "auto_day": + fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm" + if fmt == "auto": + if auto_limit == 0: + now = datetime.datetime.now(tz_info) + # we want to use given tz_info, so we don't use date() or today() + today = datetime.datetime(year=now.year, month=now.month, day=now.day, + tzinfo=now.tzinfo) + today = calendar.timegm(today.utctimetuple()) + if timestamp < today: + fmt = auto_old_fmt + else: + fmt = auto_new_fmt + else: + days_delta = (time.time() - timestamp) / 3600 + if days_delta > (auto_limit or 7): + fmt = auto_old_fmt + else: + fmt = auto_new_fmt + + if fmt == "relative": + delta = timestamp - time.time() + return dates.format_timedelta( + delta, granularity="minute", add_direction=True, locale=locale_str + ) + elif fmt in ("short", "long", "full"): + if date_only: + dt = datetime.datetime.fromtimestamp(timestamp, tz_info) + return dates.format_date(dt, format=fmt, locale=locale_str) + else: + return dates.format_datetime(timestamp, format=fmt, locale=locale_str, + tzinfo=tz_info) + elif fmt == "iso": + if date_only: + fmt = "yyyy-MM-dd" + else: + fmt = "yyyy-MM-ddTHH:mm:ss'Z'" + return dates.format_datetime(timestamp, format=fmt) + else: + return dates.format_datetime(timestamp, format=fmt, locale=locale_str, + tzinfo=tz_info) + + +def delta2human(start_ts: Union[float, int], end_ts: Union[float, int]) -> str: + """Convert delta of 2 unix times to human readable text + + @param start_ts: timestamp of starting time + @param end_ts: timestamp of ending time + """ + if end_ts < start_ts: + raise exceptions.InternalError( + "end timestamp must be bigger or equal to start timestamp !" + ) + rd = relativedelta( + datetime.datetime.fromtimestamp(end_ts), + datetime.datetime.fromtimestamp(start_ts) + ) + text_elems = [] + for unit in ("years", "months", "days", "hours", "minutes"): + value = getattr(rd, unit) + if value == 1: + # we remove final "s" when there is only 1 + text_elems.append(f"1 {unit[:-1]}") + elif value > 1: + text_elems.append(f"{value} {unit}") + + return ", ".join(text_elems) + + +def get_timezone_name(tzinfo, timestamp: Union[float, int]) -> str: + """ + Get the DST-aware timezone name for a given timezone and timestamp. + + @param tzinfo: The timezone to get the name for + @param timestamp: The timestamp to use, as a Unix timestamp (number of seconds since + the Unix epoch). + @return: The DST-aware timezone name. + """ + + dt = datetime.datetime.fromtimestamp(timestamp) + dt_tz = dt.replace(tzinfo=tzinfo) + tz_name = dt_tz.tzname() + if tz_name is None: + raise exceptions.InternalError("tz_name should not be None") + return tz_name