Files
PrimoGuardBot-/bot/utils/format_time.py
2026-02-17 11:24:55 +07:00

524 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Утилиты для форматирования времени и дат
"""
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')