From 626953fbf3d6cb16eef814314132912ef3911ca9 Mon Sep 17 00:00:00 2001 From: Verum Date: Mon, 23 Feb 2026 14:19:20 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=82=D0=B8=D0=BB=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/utils/format_time.py | 523 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 bot/utils/format_time.py diff --git a/bot/utils/format_time.py b/bot/utils/format_time.py new file mode 100644 index 0000000..91e4f0d --- /dev/null +++ b/bot/utils/format_time.py @@ -0,0 +1,523 @@ +""" +Утилиты для форматирования времени и дат +""" +from typing import Optional, Union +from datetime import datetime, timedelta +from enum import Enum + +__all__ = ( + 'format_duration', + 'format_retry_time', + 'format_timestamp', + 'format_relative_time', + 'parse_duration', + 'TimeFormat', + 'get_plural_form', + 'seconds_to_human', + 'time_until', + 'time_since', + 'format_date_range', + 'is_today', + 'is_yesterday', + 'is_tomorrow', + 'smart_date' +) + + +class TimeFormat(str, Enum): + """Форматы времени""" + FULL = 'full' # 1 час 30 минут 45 секунд + SHORT = 'short' # 1ч 30м 45с + COMPACT = 'compact' # 1:30:45 + MINIMAL = 'minimal' # 1ч 30м (без секунд если есть часы/минуты) + + +def get_plural_form(number: int, forms: tuple[str, str, str]) -> str: + """ + Возвращает правильную форму множественного числа для русского языка. + + Args: + number: Число + forms: Кортеж форм (1 секунда, 2 секунды, 5 секунд) + + Returns: + str: Правильная форма + + Example: + >> get_plural_form(1, ('секунда', 'секунды', 'секунд')) + 'секунда' + >> get_plural_form(2, ('секунда', 'секунды', 'секунд')) + 'секунды' + >> get_plural_form(5, ('секунда', 'секунды', 'секунд')) + 'секунд' + """ + n = abs(number) + n %= 100 + + if 5 <= n <= 20: + return forms[2] + + n %= 10 + + if n == 1: + return forms[0] + elif 2 <= n <= 4: + return forms[1] + else: + return forms[2] + + +def format_duration( + seconds: int, + format_type: TimeFormat = TimeFormat.FULL, + include_seconds: bool = True, + max_units: Optional[int] = None +) -> str: + """ + Форматирует длительность в читаемый вид. + + Args: + seconds: Длительность в секундах + format_type: Тип форматирования + include_seconds: Включать секунды в вывод + max_units: Максимальное количество единиц времени (например, только часы и минуты) + + Returns: + str: Отформатированная строка + + Example: + >> format_duration(3665) + '1 час 1 минута 5 секунд' + + >> format_duration(3665, TimeFormat.SHORT) + '1ч 1м 5с' + + >> format_duration(3665, TimeFormat.COMPACT) + '1:01:05' + + >> format_duration(3665, max_units=2) + '1 час 1 минута' + """ + if seconds == 0: + if format_type == TimeFormat.FULL: + return "0 секунд" + elif format_type == TimeFormat.SHORT: + return "0с" + elif format_type == TimeFormat.COMPACT: + return "0:00" + else: + return "0с" + + # Разбиваем на единицы + weeks, remainder = divmod(seconds, 604800) # 7 * 24 * 60 * 60 + days, remainder = divmod(remainder, 86400) # 24 * 60 * 60 + hours, remainder = divmod(remainder, 3600) + minutes, secs = divmod(remainder, 60) + + # Компактный формат + if format_type == TimeFormat.COMPACT: + if weeks > 0: + return f"{weeks * 7 + days}д {hours:02d}:{minutes:02d}:{secs:02d}" + elif days > 0: + return f"{days}д {hours:02d}:{minutes:02d}:{secs:02d}" + elif hours > 0: + return f"{hours}:{minutes:02d}:{secs:02d}" + elif minutes > 0: + return f"{minutes}:{secs:02d}" + else: + return f"0:{secs:02d}" + + # Собираем части + parts = [] + units_count = 0 + + # Недели + if weeks > 0: + if format_type == TimeFormat.SHORT: + parts.append(f"{weeks}нед") + else: + week_form = get_plural_form(weeks, ('неделя', 'недели', 'недель')) + parts.append(f"{weeks} {week_form}") + units_count += 1 + if max_units and units_count >= max_units: + return ' '.join(parts) + + # Дни + if days > 0: + if format_type == TimeFormat.SHORT: + parts.append(f"{days}д") + else: + day_form = get_plural_form(days, ('день', 'дня', 'дней')) + parts.append(f"{days} {day_form}") + units_count += 1 + if max_units and units_count >= max_units: + return ' '.join(parts) + + # Часы + if hours > 0: + if format_type == TimeFormat.SHORT: + parts.append(f"{hours}ч") + else: + hour_form = get_plural_form(hours, ('час', 'часа', 'часов')) + parts.append(f"{hours} {hour_form}") + units_count += 1 + if max_units and units_count >= max_units: + return ' '.join(parts) + + # Минуты + if minutes > 0: + if format_type == TimeFormat.SHORT: + parts.append(f"{minutes}м") + else: + minute_form = get_plural_form(minutes, ('минута', 'минуты', 'минут')) + parts.append(f"{minutes} {minute_form}") + units_count += 1 + if max_units and units_count >= max_units: + return ' '.join(parts) + + # Секунды + if secs > 0 and include_seconds: + # Минимальный формат: не показываем секунды если есть часы или дни + if format_type == TimeFormat.MINIMAL and (hours > 0 or days > 0 or weeks > 0): + pass + else: + if format_type == TimeFormat.SHORT: + parts.append(f"{secs}с") + else: + second_form = get_plural_form(secs, ('секунда', 'секунды', 'секунд')) + parts.append(f"{secs} {second_form}") + + return ' '.join(parts) if parts else "0 секунд" + + +def format_retry_time(retry_after: int, format_type: TimeFormat = TimeFormat.FULL) -> str: + """ + Форматирует время повторной попытки. + + Args: + retry_after: Время в секундах до следующей попытки + format_type: Тип форматирования + + Returns: + str: Отформатированная строка + + Example: + >> format_retry_time(3665) + '1 час 1 минута 5 секунд' + + >> format_retry_time(3665, TimeFormat.SHORT) + '1ч 1м 5с' + """ + return format_duration(retry_after, format_type=format_type) + + +def format_timestamp( + timestamp: Union[int, float, datetime], + format_string: str = "%d.%m.%Y %H:%M:%S", + timezone_offset: Optional[int] = None +) -> str: + """ + Форматирует timestamp в читаемую дату. + + Args: + timestamp: Unix timestamp или datetime объект + format_string: Формат вывода + timezone_offset: Смещение часового пояса в часах + + Returns: + str: Отформатированная дата + + Example: + >> format_timestamp(1640000000) + '20.12.2021 13:33:20' + + >> format_timestamp(datetime.now(), "%d %B %Y") + '17 февраля 2026' + """ + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + else: + dt = timestamp + + # Применяем смещение часового пояса + if timezone_offset is not None: + dt = dt + timedelta(hours=timezone_offset) + + return dt.strftime(format_string) + + +def format_relative_time( + timestamp: Union[int, float, datetime], + now: Optional[datetime] = None, + detailed: bool = False +) -> str: + """ + Форматирует время относительно текущего момента. + + Args: + timestamp: Unix timestamp или datetime объект + now: Текущее время (по умолчанию datetime.now()) + detailed: Детальный формат (например "2 часа 30 минут назад" вместо "2 часа назад") + + Returns: + str: Относительное время + + Example: + >> format_relative_time(time.time() - 3600) + '1 час назад' + + >> format_relative_time(time.time() + 7200) + 'через 2 часа' + + >> format_relative_time(time.time() - 90, detailed=True) + '1 минута 30 секунд назад' + """ + if now is None: + now = datetime.now() + + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + else: + dt = timestamp + + # Вычисляем разницу + delta = now - dt + is_past = delta.total_seconds() > 0 + + seconds = abs(int(delta.total_seconds())) + + # Если меньше минуты + if seconds < 60: + if is_past: + return "только что" + else: + return "сейчас" + + # Форматируем длительность + if detailed: + duration = format_duration(seconds, TimeFormat.FULL, max_units=2) + else: + duration = format_duration(seconds, TimeFormat.FULL, max_units=1) + + if is_past: + return f"{duration} назад" + else: + return f"через {duration}" + + +def parse_duration(duration_str: str) -> Optional[int]: + """ + Парсит строку длительности в секунды. + + Args: + duration_str: Строка длительности (например "1ч 30м", "2h 15m", "90s") + + Returns: + Optional[int]: Длительность в секундах или None если не удалось распарсить + + Example: + >> parse_duration("1ч 30м") + 5400 + + >> parse_duration("2h 15m 30s") + 8130 + + >> parse_duration("90s") + 90 + """ + import re + + # Паттерны для разных единиц + patterns = { + 'weeks': r'(\d+)\s*(?:нед|w|week|weeks)', + 'days': r'(\d+)\s*(?:д|d|day|days)', + 'hours': r'(\d+)\s*(?:ч|h|hour|hours)', + 'minutes': r'(\d+)\s*(?:м|m|min|minutes)', + 'seconds': r'(\d+)\s*(?:с|s|sec|seconds)' + } + + total_seconds = 0 + + # Ищем каждую единицу + for unit, pattern in patterns.items(): + match = re.search(pattern, duration_str, re.IGNORECASE) + if match: + value = int(match.group(1)) + + if unit == 'weeks': + total_seconds += value * 604800 + elif unit == 'days': + total_seconds += value * 86400 + elif unit == 'hours': + total_seconds += value * 3600 + elif unit == 'minutes': + total_seconds += value * 60 + elif unit == 'seconds': + total_seconds += value + + return total_seconds if total_seconds > 0 else None + + +# ================= ДОПОЛНИТЕЛЬНЫЕ УТИЛИТЫ ================= + +def seconds_to_human(seconds: int) -> str: + """ + Преобразует секунды в человекопонятный формат (самая большая единица). + + Args: + seconds: Количество секунд + + Returns: + str: Человекопонятный формат + + Example: + >> seconds_to_human(3600) + '1 час' + + >> seconds_to_human(90) + '1.5 минуты' + """ + if seconds >= 604800: # Неделя + weeks = seconds / 604800 + week_form = get_plural_form(int(weeks), ('неделя', 'недели', 'недель')) + return f"{weeks:.1f} {week_form}".replace('.0', '') + elif seconds >= 86400: # День + days = seconds / 86400 + day_form = get_plural_form(int(days), ('день', 'дня', 'дней')) + return f"{days:.1f} {day_form}".replace('.0', '') + elif seconds >= 3600: # Час + hours = seconds / 3600 + hour_form = get_plural_form(int(hours), ('час', 'часа', 'часов')) + return f"{hours:.1f} {hour_form}".replace('.0', '') + elif seconds >= 60: # Минута + minutes = seconds / 60 + minute_form = get_plural_form(int(minutes), ('минута', 'минуты', 'минут')) + return f"{minutes:.1f} {minute_form}".replace('.0', '') + else: # Секунда + second_form = get_plural_form(seconds, ('секунда', 'секунды', 'секунд')) + return f"{seconds} {second_form}" + + +def time_until(target_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str: + """ + Возвращает время до указанного момента. + + Args: + target_time: Целевое время + format_type: Тип форматирования + + Returns: + str: Отформатированное время + + Example: + >> target = datetime.now() + timedelta(hours=2, minutes=30) + >> time_until(target) + '2 часа 30 минут' + """ + now = datetime.now() + delta = target_time - now + + if delta.total_seconds() <= 0: + return "уже прошло" + + seconds = int(delta.total_seconds()) + return format_duration(seconds, format_type=format_type) + + +def time_since(start_time: datetime, format_type: TimeFormat = TimeFormat.FULL) -> str: + """ + Возвращает время с указанного момента. + + Args: + start_time: Начальное время + format_type: Тип форматирования + + Returns: + str: Отформатированное время + + Example: + >> start = datetime.now() - timedelta(hours=1, minutes=15) + >> time_since(start) + '1 час 15 минут' + """ + now = datetime.now() + delta = now - start_time + + if delta.total_seconds() <= 0: + return "еще не началось" + + seconds = int(delta.total_seconds()) + return format_duration(seconds, format_type=format_type) + + +def format_date_range(start: datetime, end: datetime) -> str: + """ + Форматирует диапазон дат. + + Args: + start: Начальная дата + end: Конечная дата + + Returns: + str: Отформатированный диапазон + + Example: + >> start = datetime(2026, 2, 17, 10, 0) + >> end = datetime(2026, 2, 17, 18, 0) + >> format_date_range(start, end) + '17.02.2026 с 10:00 до 18:00' + """ + if start.date() == end.date(): + # Один день + return f"{start.strftime('%d.%m.%Y')} с {start.strftime('%H:%M')} до {end.strftime('%H:%M')}" + else: + # Разные дни + return f"с {start.strftime('%d.%m.%Y %H:%M')} до {end.strftime('%d.%m.%Y %H:%M')}" + + +def is_today(dt: datetime) -> bool: + """Проверяет, является ли дата сегодняшней""" + return dt.date() == datetime.now().date() + + +def is_yesterday(dt: datetime) -> bool: + """Проверяет, является ли дата вчерашней""" + yesterday = datetime.now().date() - timedelta(days=1) + return dt.date() == yesterday + + +def is_tomorrow(dt: datetime) -> bool: + """Проверяет, является ли дата завтрашней""" + tomorrow = datetime.now().date() + timedelta(days=1) + return dt.date() == tomorrow + + +def smart_date(dt: datetime) -> str: + """ + Умное форматирование даты (сегодня, вчера, завтра, или дата). + + Args: + dt: Дата для форматирования + + Returns: + str: Отформатированная дата + + Example: + >> smart_date(datetime.now()) + 'сегодня в 14:30' + + >> smart_date(datetime.now() - timedelta(days=1)) + 'вчера в 20:15' + """ + if is_today(dt): + return f"сегодня в {dt.strftime('%H:%M')}" + elif is_yesterday(dt): + return f"вчера в {dt.strftime('%H:%M')}" + elif is_tomorrow(dt): + return f"завтра в {dt.strftime('%H:%M')}" + else: + # Если в этом году, не показываем год + if dt.year == datetime.now().year: + return dt.strftime('%d.%m в %H:%M') + else: + return dt.strftime('%d.%m.%Y в %H:%M')