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