Первый коммит
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