Утилита форматирования времени

This commit is contained in:
2026-02-23 14:19:20 +07:00
parent bb6855458d
commit 626953fbf3

523
bot/utils/format_time.py Normal file
View File

@@ -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')