Утилита форматирования времени
This commit is contained in:
523
bot/utils/format_time.py
Normal file
523
bot/utils/format_time.py
Normal 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')
|
||||||
Reference in New Issue
Block a user