""" Утилиты для форматирования времени и дат """ 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')