commit d0baf76f8f04f4cb836d89bc7d9c76cdf5a449fb Author: admin Date: Sat Aug 30 07:39:44 2025 +0700 Первый коммит diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b1d6af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Исключить скрытые системные каталоги, но не всё подряд +.git/ +.gitattributes +.gitignore + +# Виртуальные окружения и Python-кэш +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.pyo + +# IDE-файлы +.idea/ +.vscode/ + +# Тесты и документация +tests/ +test/ +docs/ +examples/ + +# Логи и артефакты сборки +*.log +*.logs +*.log.* +*.logs.* +Logs/ +Log/ +dist/ +build/ + +# Примеры и шаблоны +.env +env diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..5bd9dfa --- /dev/null +++ b/.env_example @@ -0,0 +1,92 @@ +# Токены бота +BOT_TOKEN=your_bot_token_here +BOT_DEBUG_TOKEN=your_debug_bot_token_here + +# Режим отладки +DEBUG=False + + +# Владелец бота +OWNER=@verdise + +# Основные настройки +PARSE_MODE=HTML +ENCOD=utf-8 +TIME_FORMAT=%Y-%m-%d %H:%M:%S +PREFIX=/!.&? +BOT_LANGUAGE=Aiogram3 + + +# Настройки сообщений +DISABLE_NOTIFICATION=False +PROTECT_CONTENT=False +ALLOW_SENDING_WITHOUT_REPLY=True +LINK_PREVIEW_IS_DISABLED=False +LINK_PREVIEW_PREFER_SMALL_MEDIA=False +LINK_PREVIEW_PREFER_LARGE_MEDIA=True +LINK_PREVIEW_SHOW_ABOVE_TEXT=False +SHOW_CAPTION_ABOVE_MEDIA=False + +# Разрешения +BOT_EDIT=False +START_INFO_CONSOLE=True +START_INFO_TO_FILE=True + +# Логирование +LOG_CONSOLE=True +LOG_FILE=True +LOG_DIR=Logs +LOG_FILE_INFO=bot_info.log + + +# Вебхук +WEBHOOK=False + +# API ключи +API_KEY=your_api_key +WEB_API_KEY=your_web_api_key +WEATHER_API_KEY=your_weather_api_key + +# Telegram API ID и HASH +TG_API_UID=123456 +TG_API_HASH=your_tg_api_hash + + +# Важные ID +ADMIN_ID=123456789 +MODERATOR_ID=987654321 +IMPORTANT_ID=1122334455 +IMPORTANT_GROUP_ID=-1001122334455 +IMPORTANT_CHANNEL_ID=-1009988776655 + + +# Настройки бота +PROJECT_NAME=PRIMO +BOT_NAME=Первозданная Жемчужина +BOT_DESCRIPTION=Ваш помощник в удивительные миры! Prod. by:『@verdise』 +BOT_SHORT_DESCRIPTION=Тех.поддержка: @verdise + +# Настройки ролевого проекта +RP_NAME: str = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥" + + +# Права администратора +ANONYMOUS=False +MANAGE_CHAT=True +CHANGE_INFO=True +PROMOTE_MEMBERS=True +RESTRICT_MEMBERS=True +POST_MESSAGE=True +MANAGE_TOPICS=True +INVITE_USER=True +DELETE_MESSAGES=True +MANAGE_VIDEO_CHATS=True +EDIT_MESSAGES=True +PIN_MESSAGE=True +POST_STORIES=True +EDIT_STORIES=True +DELETE_STORIES=True + + +# Поддержка +SUPPORT_CHAT_ID=0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a679a5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,97 @@ +# ============================================================================= +# Git LFS: большие бинарные файлы, модели, архивы +# ============================================================================= +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text + +# ============================================================================= +# Автоопределение текста, окончания строк +# ============================================================================= +* text=auto eol=lf + +# ============================================================================= +# Текстовые файлы (Python, конфиги, документы) +# ============================================================================= +*.py text +*.pyi text +*.ipynb text +*.html text +*.css text +*.js text +*.json text +*.md text +*.yml text +*.yaml text +*.xml text +*.txt text +*.cfg text +*.toml text +*.ini text +*.env text + +# ============================================================================= +# Изображения +# ============================================================================= +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.bmp binary +*.webp binary +*.ico binary +*.svg text + +# ============================================================================= +# Шрифты +# ============================================================================= +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.otf binary + +# ============================================================================= +# GitHub Linguist (указание языка для отображения) +# ============================================================================= +*.py linguist-language=Python +*.ipynb linguist-language=Jupyter Notebook +*.html linguist-language=HTML +*.css linguist-language=CSS +*.js linguist-language=JavaScript +*.json linguist-language=JSON +*.md linguist-language=Markdown +*.yml linguist-language=YAML +*.yaml linguist-language=YAML diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf694fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# .gitignore: Игнорируемые файлы для Python проектов +# Подробнее: https://github.com/github/gitignore/blob/main/Python.gitignore + +### Python ### +# Виртуальные окружения и настройки +.venv +.env +env +venv/ +env/ +env.bak/ +venv.bak/ + +# Кэш интерпретатора +__pycache__/ +*.py[cod] +*$py.class + +# Пакеты и сборки +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.eg +*.egg +*.eggs + +# Poetry +poetry.lock +.pypoetry/ + +### Логи и БД ### +*.log +*.logs +*.log.* +*.logs.* +log/ +logs/ +*.sqlite +*.db + +### IDE ### +.idea/ +.vscode/ +*.swp +*.sublime-* + +### OS ### +.DS_Store +Thumbs.db + +### Тестирование ### +.coverage +htmlcov/ +.tox/ +.nox/ +.pytest_cache/ +.mypy_cache/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/PrimoExampleBot.iml b/.idea/PrimoExampleBot.iml new file mode 100644 index 0000000..24fb5f2 --- /dev/null +++ b/.idea/PrimoExampleBot.iml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..459f791 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cbd8d7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Используем официальный образ Python с подходящей версией +FROM python:3.12-slim + +# Устанавливаем Poetry +RUN pip install poetry + +# Устанавливаем рабочую директорию внутри контейнера +WORKDIR /app + +# Копируем файлы Poetry +COPY pyproject.toml poetry.lock* ./ + +# Настраиваем Poetry (не создавать виртуальное окружение внутри контейнера) +RUN poetry config virtualenvs.create false + +# Устанавливаем зависимости через Poetry +RUN poetry install --no-interaction --no-ansi --no-root + +# Копируем все файлы проекта внутрь контейнера +COPY . . + +# Устанавливаем переменную окружения для буферизации +ENV PYTHONUNBUFFERED=1 + +# Команда запуска — запуск скрипта main.py +CMD ["python", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..889866f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2025] [Verum] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e963fd8 Binary files /dev/null and b/README.md differ diff --git a/assets/default.jpg b/assets/default.jpg new file mode 100644 index 0000000..a024412 Binary files /dev/null and b/assets/default.jpg differ diff --git a/assets/start.jpg b/assets/start.jpg new file mode 100644 index 0000000..2fb9564 Binary files /dev/null and b/assets/start.jpg differ diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..7d3ce67 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,2 @@ +from .core import * +from .handlers import * diff --git a/bot/core/__init__.py b/bot/core/__init__.py new file mode 100644 index 0000000..f6efb9b --- /dev/null +++ b/bot/core/__init__.py @@ -0,0 +1,2 @@ +from .bots import * +from .webhook import * diff --git a/bot/core/bots.py b/bot/core/bots.py new file mode 100644 index 0000000..92378cc --- /dev/null +++ b/bot/core/bots.py @@ -0,0 +1,260 @@ +from datetime import datetime +from time import sleep + +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription +from aiogram.utils.i18n import I18n, SimpleI18nMiddleware + +from configs.config import BotSettings, BotEdit, Webhook, Permission +from middleware.loggers import log + +# Экспортируем объекты модуля +__all__ = ("dp", "bot", "BotInfo", "i18n",) + + +# Диспетчер бота, языковых настроек и его хранилища +storage: MemoryStorage = MemoryStorage() +dp: Dispatcher = Dispatcher(storage=storage) +dp["is_active"]: bool = True + + +# Инициализация i18n +i18n: I18n = I18n(path="locales", default_locale="ru", domain="bot") +i18n_middleware: SimpleI18nMiddleware = SimpleI18nMiddleware(i18n=i18n) +i18n_middleware.setup(dp) + + +# Экземпляр бота с настройками по умолчанию +bot: Bot = Bot(token=BotSettings.BOT_TOKEN, + default=DefaultBotProperties( + parse_mode=BotSettings.PARSE_MODE, + disable_notification=BotSettings.DISABLE_NOTIFICATION, + protect_content=BotSettings.PROTECT_CONTENT, + allow_sending_without_reply=BotSettings.ALLOW_SENDING_WITHOUT_REPLY, + link_preview_is_disabled=BotSettings.LINK_PREVIEW_IS_DISABLED, + link_preview_prefer_small_media=BotSettings.LINK_PREVIEW_PREFER_SMALL_MEDIA, + link_preview_prefer_large_media=BotSettings.LINK_PREVIEW_PREFER_LARGE_MEDIA, + link_preview_show_above_text=BotSettings.LINK_PREVIEW_SHOW_ABOVE_TEXT, + show_caption_above_media=BotSettings.SHOW_CAPTION_ABOVE_MEDIA + ) +) + + +class BotInfo: + """Класс для хранения и инициализации данных бота.""" + id: int = None + url: str = None + first_name: str = None + last_name: str = None + username: str = None + description: str = None + short_description: str = None + language_code: str = BotSettings.BOT_LANGUAGE + prefix: str = BotSettings.PREFIX + bot_owner: str = BotSettings.OWNER + added_to_attachment_menu: bool = False + supports_inline_queries: bool = False + can_connect_to_business: bool = False + has_main_web_app: bool = False + can_join_groups: bool = False + can_read_all_group_messages: bool = False + + + @classmethod + @log(level='INFO', log_type='BOT', text='Настройка вебхука бота') + async def webhook(cls, bots: Bot = bot, webhook_url: str = Webhook.WEBHOOK_URL, + use_webhook: bool = Webhook.WEBHOOK) -> None: + """ + Удаление или установка вебхука. + + :param bots: Объект бота для управления. + :param use_webhook: Статус использования вебхука, поумолчанию (true). + :param webhook_url: Ссылка на вебхук. + """ + # Удаляем текущий вебхук + await bots.delete_webhook(drop_pending_updates=True) + + # Если включен вебхук — устанавливаем новый + if use_webhook: + if webhook_url is None: + raise ValueError("Для установки вебхука необходимо указать webhook_url") + await bots.set_webhook(webhook_url) + + + + @classmethod + @log(level='INFO', log_type='BOT', text='Получение информации о боте') + async def info(cls, bots: Bot = bot) -> dict: + """ + Получает и сохраняет информацию о боте. + + :param bots: Объект бота для управления. + :return: Словарь с персональными данными о боте. + """ + bot_info: User = await bots.get_me() + + cls.id = bot_info.id + cls.url = f'tg://user?id={cls.id}' + cls.first_name = bot_info.first_name + cls.last_name = bot_info.last_name + cls.username = bot_info.username + cls.description = getattr(bot_info, 'description', '') + cls.short_description = getattr(bot_info, 'short_description', '') + cls.language_code = bot_info.language_code + cls.is_premium = bot_info.is_premium + cls.added_to_attachment_menu = bot_info.added_to_attachment_menu + cls.supports_inline_queries = bot_info.supports_inline_queries + cls.can_connect_to_business = bot_info.can_connect_to_business + cls.has_main_web_app = bot_info.has_main_web_app + cls.can_join_groups = getattr(bot_info, 'can_join_groups', False) + cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False) + + return { + 'id': cls.id, + 'url': cls.url, + 'first_name': cls.first_name, + 'last_name': cls.last_name, + 'username': cls.username, + 'description': cls.description, + 'short_description': cls.short_description, + 'language_code': cls.language_code, + 'prefix': cls.prefix, + 'bot_owner': cls.bot_owner, + 'is_premium': cls.is_premium, + 'added_to_attachment_menu': cls.added_to_attachment_menu, + 'supports_inline_queries': cls.supports_inline_queries, + 'can_connect_to_business': cls.can_connect_to_business, + 'has_main_web_app': cls.has_main_web_app, + 'can_join_groups': cls.can_join_groups, + 'can_read_all_group_messages': cls.can_read_all_group_messages, + } + + + @staticmethod + @log(level='INFO', log_type='BOT', text='Установка прав администратора') + async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = BotEdit.RIGHTS) -> None: + """ + Устанавливает права администратора по умолчанию. + + :param bots: Объект бота для управления. + :param rights: Заданные права администратора бота, по умолчанию словарь из конфигов. + """ + bot_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights() + + if bot_rights != rights: + await bots.set_my_default_administrator_rights(rights) + + + @staticmethod + @log(level='INFO', log_type='BOT', text='Обновление имени бота') + async def set_name(bots: Bot = bot, new_name: str = BotEdit.NAME) -> None: + """ + Устанавливает имя бота из конфига. + + :param bots: Объект бота для управления. + :param new_name: Новое имя бота, по умолчанию из конфигов. + """ + current_name: str = (await bots.get_me()).first_name + + if not (1 <= len(new_name) <= 32): + raise ValueError("Имя бота должно быть от 1 до 32 символов.") + + if current_name != new_name: + await bots.set_my_name(new_name) + + + @staticmethod + @log(level='INFO', log_type='BOT', text='Обновление описания бота') + async def set_description(bots: Bot = bot, new_description: str = BotEdit.DESCRIPTION) -> None: + """ + Устанавливает полное описание бота. + + :param bots: Объект бота для управления. + :param new_description: Новое описание бота, по умолчанию из конфигов. + """ + current_description: BotDescription = await bots.get_my_description() + + if not (0 < len(new_description) <= 255): + raise ValueError("Описание должно быть от 1 до 255 символов.") + + if current_description != new_description: + await bots.set_my_description(description=new_description) + + + @staticmethod + @log(level='INFO', log_type='BOT', text='Обновление короткого описания бота') + async def set_short_description(bots: Bot = bot, new_short: str = BotEdit.SHORT_DESCRIPTION) -> None: + """ + Устанавливает короткое описание виджета. + + :param bots: Объект бота для управления. + :param new_short: Новое короткое описание бота, по умолчанию из конфигов. + """ + current_short: BotShortDescription = await bots.get_my_short_description() + + if not (0 < len(new_short) <= 512): + raise ValueError("Короткое описание должно быть от 1 до 512 символов.") + + if current_short != new_short: + await bots.set_my_short_description(short_description=new_short) + + + @staticmethod + def start_info_out() -> str: + bot_time: str = f"Бот @{BotInfo.username} запущен в {datetime.now().strftime("%S:%M:%H %d-%m-%Y")}\n" + bot_name: str = f"Основное имя: {BotInfo.first_name}\n" + bot_postname: str = f" Доп. имя: {BotInfo.last_name}\n" + bot_username: str = f" Юзернейм: @{BotInfo.username}\n" + bot_id: str = f" ID: {BotInfo.id}\n" + bot_can_join_groups: str = f" Может ли вступать в группы: {BotInfo.can_join_groups}\n" + bot_can_read_all_group_messages: str = f" Чтение всех сообщений: {BotInfo.can_read_all_group_messages}\n" + bot_added_to_attachment_menu: str = f" Добавлен в меню вложений: {BotInfo.added_to_attachment_menu}\n" + bot_supports_inline_queries: str = f" Поддерживает инлайн-запросы: {BotInfo.supports_inline_queries}\n" + bot_can_connect_to_business: str = f" Подключение к бизнес-аккаунтам: {BotInfo.can_connect_to_business}\n" + bot_has_main_web_app: str = f" Основное веб-приложение: {BotInfo.has_main_web_app}\n" + + # Формируем полный текст с выводом информации о боте + bot_all_info: str = (f"{bot_name} {bot_postname} {bot_username} {bot_id} " + f"{bot_can_join_groups} {bot_can_read_all_group_messages} " + f"{bot_added_to_attachment_menu} {bot_supports_inline_queries} {bot_can_connect_to_business} " + f"{bot_has_main_web_app}") + + # Печатаем все данные в консоль с задержкой в 1 секунду + sleep(1) + print(f"\033[34m{bot_all_info}\033[0m") + + # Записываем информацию в файл + try: + with open("Logs/info.log", 'w', encoding='utf-8') as log_file: + log_file.write(f"{bot_time}{bot_all_info}") + + # Создание файла bot_start.log + with open("Logs/bot_start.log", 'a', encoding='utf-8') as log_start_file: + log_start_file.write(f"{bot_time}\n") + return bot_all_info + + # Проверка на ошибку и ее логирование + except Exception as e: + error: str = f"Ошибка при получении ID пользователя: {e}" + raise error + + + @classmethod + @log(level='INFO', log_type='START', text='Процесс запуска бота!') + async def setup(cls, bots: Bot = bot, perm: bool = Permission.BOT_EDIT): + """ + Выполняет полную настройку бота. + + :param perm: Разрешение на изменения бота. + :param bots: Объект бота для управления. + """ + await cls.webhook(bots=bots) + await cls.info(bots=bots) + if perm: + await cls.set_administrator_rights(bots=bots) + await cls.set_description(bots=bots) + await cls.set_short_description(bots=bots) + await cls.set_name(bots=bots) + cls.start_info_out() diff --git a/bot/core/webhook.py b/bot/core/webhook.py new file mode 100644 index 0000000..ac03218 --- /dev/null +++ b/bot/core/webhook.py @@ -0,0 +1,37 @@ +from typing import Any + +from fastapi import FastAPI, Request +from uvicorn import Config, Server +from aiogram.types import Update + +from configs import Webhook +from .bots import dp, bot + +# Настройки экспорта +__all__ = ("app", "config", "server",) + + +# Создаём FastAPI приложение +app: FastAPI = FastAPI() + +# Создаём конфиг для uvicorn +config: Config = Config( + app="bot.core.webhook:app", + host=Webhook.WEBAPP_HOST, + port=Webhook.WEBAPP_PORT, + log_level=Webhook.LOG_LEVEL, # выводить только предупреждения и ошибки + access_log=Webhook.ACCES_LOG # <-- отключает все HTTP-access логи +) + +# Создание вебхук-сервера +server: Server = Server(config) + + +@app.post("/webhook") +async def telegram_webhook(request: Request) -> dict[str, Any]: + """ + Обработчик POST-запроса от Telegram. + """ + update: Update = Update(**await request.json()) + await dp.feed_update(bot, update) + return {"ok": True} diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py new file mode 100644 index 0000000..bd658ac --- /dev/null +++ b/bot/filters/__init__.py @@ -0,0 +1,5 @@ +from .callback import * +from .chat_rights import * +from .chat_type import * +from .message_content import * +from .subscrided import * diff --git a/bot/filters/callback.py b/bot/filters/callback.py new file mode 100644 index 0000000..8a51146 --- /dev/null +++ b/bot/filters/callback.py @@ -0,0 +1,21 @@ +from aiogram.filters import BaseFilter +from aiogram.types import CallbackQuery + +# Настройка экспорта +__all__ = ("CallbackDataStartsWith",) + + +class CallbackDataStartsWith(BaseFilter): + """ + Фильтр для callback_data, начинающихся с префикса. + + Example: + @router.callback_query(CallbackDataStartsWith("menu:")) + async def handler(cb: CallbackQuery): + await cb.answer("Это callback из меню ✅") + """ + def __init__(self, prefix: str) -> None: + self.prefix = prefix + + async def __call__(self, callback: CallbackQuery) -> bool: + return bool(callback.data and callback.data.startswith(self.prefix)) diff --git a/bot/filters/chat_rights.py b/bot/filters/chat_rights.py new file mode 100644 index 0000000..147a692 --- /dev/null +++ b/bot/filters/chat_rights.py @@ -0,0 +1,73 @@ +from aiogram import Bot +from aiogram.filters import BaseFilter +from aiogram.types import Message, ResultChatMemberUnion +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +# Настройка экспорта +__all__ = ("IsChatCreator", "IsAdmin", "IsModerator",) + + +class IsChatCreator(BaseFilter): + """ + Пользователь является создателем чата. + + Example: + @router.message(IsChatCreator()) + async def handler(msg: Message): + await msg.answer("Ты создатель этого чата 👑") + """ + async def __call__(self, message: Message, bot: Bot) -> bool: + try: + member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id) + return member.status == "creator" + except (TelegramBadRequest, TelegramForbiddenError): + return False + + +class IsAdmin(BaseFilter): + """ + Пользователь является администратором (или создателем). + + Example: + @router.message(IsAdmin()) + async def handler(msg: Message): + await msg.answer("Ты админ ✅") + """ + async def __call__(self, message: Message, bot: Bot) -> bool: + try: + member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id) + return member.status in {"administrator", "creator"} + except (TelegramBadRequest, TelegramForbiddenError): + return False + + +class IsModerator(BaseFilter): + """ + Администратор с модераторскими правами: + - удаление сообщений + - ограничение пользователей + - закрепление сообщений + + Example: + @router.message(IsModerator()) + async def handler(msg: Message): + await msg.answer("Ты модератор ✅") + """ + async def __call__(self, message: Message, bot: Bot) -> bool: + try: + member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id) + + if member.status == "creator": + return True + if member.status != "administrator": + return False + + required_rights: list[bool] = [ + getattr(member, "can_delete_messages", False), + getattr(member, "can_restrict_members", False), + getattr(member, "can_pin_messages", False), + ] + return all(required_rights) + + except (TelegramBadRequest, TelegramForbiddenError): + return False diff --git a/bot/filters/chat_type.py b/bot/filters/chat_type.py new file mode 100644 index 0000000..9b1f1a1 --- /dev/null +++ b/bot/filters/chat_type.py @@ -0,0 +1,31 @@ +from aiogram.filters import BaseFilter +from aiogram.types import Message + +# Настройка экспорта +__all__ = ("IsPrivate", "IsGroup",) + + +class IsPrivate(BaseFilter): + """ + Сообщение в личке с ботом. + + Example: + @router.message(IsPrivate()) + async def handler(msg: Message): + await msg.answer("Это ЛС ✅") + """ + async def __call__(self, message: Message) -> bool: + return message.chat.type == "private" + + +class IsGroup(BaseFilter): + """ + Сообщение в группе или супергруппе. + + Example: + @router.message(IsGroup()) + async def handler(msg: Message): + await msg.answer("Это сообщение в группе ✅") + """ + async def __call__(self, message: Message) -> bool: + return message.chat.type in {"group", "supergroup"} diff --git a/bot/filters/message_content.py b/bot/filters/message_content.py new file mode 100644 index 0000000..595dc7d --- /dev/null +++ b/bot/filters/message_content.py @@ -0,0 +1,67 @@ +from aiogram.filters import BaseFilter +from aiogram.types import Message + +# Настройка экспорта +__all__ = ("IsReply", "IsForwarded", "HasMedia", "ContainsURL",) + + +class IsReply(BaseFilter): + """ + Сообщение является ответом. + + Example: + @router.message(IsReply()) + async def handler(msg: Message): + await msg.answer("Это реплай ✅") + """ + async def __call__(self, message: Message) -> bool: + return message.reply_to_message is not None + + +class IsForwarded(BaseFilter): + """ + Сообщение переслано из другого чата/от пользователя. + + Example: + @router.message(IsForwarded()) + async def handler(msg: Message): + await msg.answer("Это пересланное сообщение 🔄") + """ + async def __call__(self, message: Message) -> bool: + return (message.forward_from is not None) or (message.forward_from_chat is not None) + + +class HasMedia(BaseFilter): + """ + Сообщение содержит медиа (фото, видео, документ и т.д.). + + Example: + @router.message(HasMedia()) + async def handler(msg: Message): + await msg.answer("Это медиа ✅") + """ + async def __call__(self, message: Message) -> bool: + return any([ + message.photo, + message.video, + message.document, + message.audio, + message.voice, + message.video_note, + message.sticker, + ]) + + +class ContainsURL(BaseFilter): + """ + Сообщение содержит ссылку (http/https). + + Example: + @router.message(ContainsURL()) + async def handler(msg: Message): + await msg.answer("Это сообщение с ссылкой 🔗") + """ + async def __call__(self, message: Message) -> bool: + if not message.text: + return False + return "http://" in message.text or "https://" in message.text diff --git a/bot/filters/subscrided.py b/bot/filters/subscrided.py new file mode 100644 index 0000000..a8b019c --- /dev/null +++ b/bot/filters/subscrided.py @@ -0,0 +1,39 @@ +from aiogram.types import Message, ResultChatMemberUnion +from aiogram.filters import BaseFilter +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from typing import Union + +# Настройки экспорта +__all__ = ("FilterSubscribed",) + + +class FilterSubscribed(BaseFilter): + """ + Фильтр для проверки подписки пользователя на один или несколько каналов. + Поддерживает как публичные каналы (username), так и приватные (ID). + + Пример: + # Проверка сразу двух каналов: публичный по username и приватный по ID + @router.message(FilterSubscribed(["@public_channel", -1001234567890])) + async def only_subscribed(message: Message): + await message.answer("Ты подписан и на публичный, и на приватный канал ✅") + """ + def __init__(self, channels: list[Union[str, int]]) -> None: + self.channels = channels + + async def __call__(self, message: Message, bot: Bot) -> bool: + for channel in self.channels: + try: + member: ResultChatMemberUnion = await bot.get_chat_member( + chat_id=channel, + user_id=message.from_user.id + ) + if member.status in ("left", "kicked"): + return False + + except (TelegramBadRequest, TelegramForbiddenError): + # Канал недоступен, либо у бота нет прав + return False + + return True diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..6497a28 --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,14 @@ +from aiogram import Router +from bot.handlers.commands import router as cmd_routers +from .messages import router as messages_routers + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + cmd_routers, + messages_routers, + +) diff --git a/bot/handlers/commands/__init__.py b/bot/handlers/commands/__init__.py new file mode 100644 index 0000000..5ee813c --- /dev/null +++ b/bot/handlers/commands/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router +from .admins import router as admin_cmd_router +from .users import router as users_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + admin_cmd_router, + users_cmd_router, +) diff --git a/bot/handlers/commands/admins/__init__.py b/bot/handlers/commands/admins/__init__.py new file mode 100644 index 0000000..409f762 --- /dev/null +++ b/bot/handlers/commands/admins/__init__.py @@ -0,0 +1,11 @@ +from aiogram import Router +from .settings_cmd import router as settings_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + settings_cmd_router, +) diff --git a/bot/handlers/commands/admins/settings_cmd.py b/bot/handlers/commands/admins/settings_cmd.py new file mode 100644 index 0000000..f65094d --- /dev/null +++ b/bot/handlers/commands/admins/settings_cmd.py @@ -0,0 +1,51 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.i18n import gettext as _ + +from bot.templates import msg_photo +from bot.utils.interesting_facts import interesting_fact +from bot.core.bots import BotInfo +from configs import COMMANDS, RpValue + +# Настройки экспорта и роутера +__all__ = ("router",) +CMD: str = "settings".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.callback_query(F.data.lower() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /start""" + await state.clear() + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=RpValue.INFO_URL)) + ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'), + InlineKeyboardButton(text="Анкета📖", callback_data='anketa')) + ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin')) + + # Формируем приветственное сообщение + text: str = _( + """Добро пожаловать, {name}! + +Я ваш искусственный помощник по ролевой - {rp_name}! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! + +Интересный факт: +
{fact}
+""" + ).format( + url=message.from_user.url if message.from_user else "", + name=message.from_user.first_name if message.from_user else "пользователь", + rp_name=RpValue.RP_NAME, + fact=interesting_fact(), + ) + + # Отправляем сообщение + await msg_photo(message=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb) diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py new file mode 100644 index 0000000..84c8ebb --- /dev/null +++ b/bot/handlers/commands/users/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router +from .start_cmd import router as start_cmd_router +from .active import router as active_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + start_cmd_router, + active_cmd_router, +) diff --git a/bot/handlers/commands/users/active.py b/bot/handlers/commands/users/active.py new file mode 100644 index 0000000..a964c06 --- /dev/null +++ b/bot/handlers/commands/users/active.py @@ -0,0 +1,45 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery + +from bot.templates import msg_photo +from bot.core.bots import BotInfo +from configs import COMMANDS +from database import db + + + +# Настройки экспорта и роутера +__all__ = ("router",) + + +CMD: str = "active".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.callback_query(F.data.lower() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +async def active_cmd(message: Message | CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /active""" + await state.clear() + + # Получить статистику сообщений пользователя + day, week, month, total = await db.get_message_stats(message.from_user.id) + + print(f"За день: {day} сообщений") + print(f"За неделю: {week} сообщений") + print(f"За месяц: {month} сообщений") + print(f"Всего: {total} сообщений") + + # Формируем приветственное сообщение + text: str =f""" +За день: {day} сообщений +За неделю: {week} сообщений +За месяц: {month} сообщений +Всего: {total} сообщений +""" + + + # Отправляем сообщение + await msg_photo(message=message, text=text,) diff --git a/bot/handlers/commands/users/anketa_cmd.py b/bot/handlers/commands/users/anketa_cmd.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/commands/users/new_cmd.py b/bot/handlers/commands/users/new_cmd.py new file mode 100644 index 0000000..6e4dc2d --- /dev/null +++ b/bot/handlers/commands/users/new_cmd.py @@ -0,0 +1,218 @@ +import re +from typing import Optional, Dict, Tuple + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.i18n import gettext as _ + +from bot.core.bots import BotInfo +from bot.keyboards.inline.decision import decision_keyboard +from bot.states.new_states import NewStates +from bot.templates import msg +from middleware.loggers import log +from configs import COMMANDS, ImportantID, RpValue + +# Глобальная мапа для хранения связей пользователь-топик +user_topic_map: Dict[Tuple[int, str], int] = {} + +__all__ = ("router",) +CMD: str = "new" +router: Router = Router(name=f"{CMD}_cmd_router") +TOPIC_TYPE: str = "anketa" + +TEXTS: Dict[str, Dict[str, str]] = { + "anketa": { + "accept": f"🎉 Ваша анкета принята!\n\nДобро пожаловать в проект!\n\nФлуд: {RpValue.FLUD_URL}\nРолевая: {RpValue.RP_URL}", + "reject": "❌ Ваша анкета отклонена.\n\nВы можете попробовать позже." + } +} + + +async def validate_russian_text(text: str) -> bool: + """Проверяет текст на соответствие русским буквам, пробелам и дефисам.""" + return bool(re.fullmatch(r"[А-Яа-яЁё\s\-]+", text)) + + +# ===================== Команда /new ===================== +@router.callback_query(F.data == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}") +async def new_cmd(message: Message | CallbackQuery, state: FSMContext) -> None: + """ + Начало анкеты /new. + Отправляет пользователю сообщение с просьбой указать желаемую роль. + """ + await state.clear() + await state.set_state(NewStates.role) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + text: str = _( + "Пожалуйста, отправьте желаемую роль:\n" + "(только русские буквы, пробелы или дефисы)" + ) + + await msg(message=message, text=text, markup=ikb) + + +# ===================== Обработка роли ===================== +@router.message(NewStates.role) +async def process_role(message: Message, state: FSMContext) -> None: + """Обрабатывает ввод роли и запрашивает сортол.""" + if not await validate_russian_text(message.text): + await message.reply("Ошибка: роль должна содержать только русские буквы, пробелы или дефисы.") + return + + await state.update_data(role=message.text.strip().title()) + await state.set_state(NewStates.sorol) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + await message.reply( + text="Теперь укажите желаемый сортол:\n(только русские буквы, пробелы или дефисы)", + reply_markup=ikb.as_markup() + ) + + +# ===================== Обработка сортола ===================== +@router.message(NewStates.sorol) +async def process_sortol(message: Message, state: FSMContext) -> None: + """Обрабатывает ввод сортола и запрашивает кодовую фразу.""" + if not await validate_russian_text(message.text): + await message.reply("Ошибка: сорол должен содержать только русские буквы, пробелы или дефисы.") + return + + await state.update_data(sortol=message.text.strip().title()) + await state.set_state(NewStates.code_phrase) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + await message.reply( + text="Теперь введите кодовую фразу из правил:", + reply_markup=ikb.as_markup() + ) + + +# ===================== Обработка кодовой фразы ===================== +@router.message(NewStates.code_phrase) +async def process_code_phrase(message: Message, state: FSMContext) -> None: + """Обрабатывает ввод кодовой фразы и показывает предпросмотр анкеты.""" + code_phrase = message.text.strip() + if not code_phrase: + await message.reply("Кодовая фраза не может быть пустой.") + return + + await state.update_data(code_phrase=code_phrase) + data: Dict[str, str] = await state.get_data() + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row( + InlineKeyboardButton(text="Отправить!", callback_data="submit_new"), + InlineKeyboardButton(text="Отмена↩️", callback_data="start") + ) + + text: str = ( + f"Проверьте данные анкеты:\n\n" + f"• Роль: {data['role']}\n" + f"• Сортол: {data['sortol']}\n" + f"• Кодовая фраза: {data['code_phrase']}" + ) + + await message.reply(text, reply_markup=ikb.as_markup()) + + +# ===================== Отправка анкеты в поддержку ===================== +@router.callback_query(F.data == "submit_new") +async def submit_new_cmd(callback: CallbackQuery, state: FSMContext) -> None: + """Отправляет анкету в топик форума поддержки и создает запись в мапе.""" + data: Dict[str, str] = await state.get_data() + user = callback.from_user + + # Создаем топик в форуме + topic = await callback.bot.create_forum_topic( + chat_id=ImportantID.SUPPORT_CHAT_ID, + name=f"Анкета от {user.full_name}" + ) + thread_id: int = topic.message_thread_id + + # Сохраняем связь пользователь-топик + user_topic_map[(user.id, TOPIC_TYPE)] = thread_id + + # Формируем текст анкеты + text: str = ( + f'Анкета\n\n' + f"• Роль: {data['role']}\n" + f"• Сортол: {data['sortol']}\n" + f"• Кодовая фраза: {data['code_phrase']}" + ) + + # Отправляем в топик с кнопками принятия/отклонения + await callback.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=text, + parse_mode="HTML", + reply_markup=decision_keyboard(thread_id=thread_id, kind=TOPIC_TYPE) + ) + + await callback.message.edit_text("✅ Ваша анкета успешно отправлена на рассмотрение!") + await state.clear() + + +# ===================== Обработка решения админов ===================== +@router.callback_query(F.data.regexp(r"^([a-z_]+):(accept|reject):(\d+)$")) +async def process_decision_callback(callback: CallbackQuery) -> None: + """Обрабатывает решение администраторов и отправляет результат пользователю.""" + kind, action, thread_id_str = callback.data.split(":") + thread_id = int(thread_id_str) + + # Ищем пользователя по thread_id в мапе + user_id = None + for (uid, k), tid in user_topic_map.items(): + if k == kind and tid == thread_id: + user_id = uid + break + + if not user_id: + await callback.answer("Пользователь не найден.", show_alert=True) + return + + text_to_send: Optional[str] = TEXTS.get(kind, {}).get(action) + if not text_to_send: + await callback.answer("Некорректные данные.", show_alert=True) + return + + await callback.bot.send_message(chat_id=user_id, text=text_to_send, parse_mode="HTML") + await callback.message.edit_reply_markup(reply_markup=None) + await callback.answer("Ответ отправлен пользователю.") + + +# ===================== Пересылка ответов админов пользователю ===================== +@router.message(F.is_topic_message, F.reply_to_message, ~F.from_user.is_bot) +async def forward_reply_to_user(message: Message) -> None: + """Пересылает ответы администраторов из топика пользователю.""" + thread_id = message.message_thread_id + if not thread_id: + return + + # Ищем пользователя по thread_id + user_id = None + for (uid, _), tid in user_topic_map.items(): + if tid == thread_id: + user_id = uid + break + + if not user_id: + return + + reply_text: str = f"Ответ администратора:\n{message.html_text}" + try: + await message.bot.send_message(chat_id=user_id, text=reply_text, parse_mode="HTML") + except Exception as e: + await message.reply(f"⚠️ Не удалось отправить сообщение пользователю: {e}") \ No newline at end of file diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py new file mode 100644 index 0000000..d213f48 --- /dev/null +++ b/bot/handlers/commands/users/start_cmd.py @@ -0,0 +1,51 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.i18n import gettext as _ + +from bot.templates import msg_photo +from bot.utils.interesting_facts import interesting_fact +from bot.core.bots import BotInfo +from configs import COMMANDS, RpValue + +# Настройки экспорта и роутера +__all__ = ("router",) +CMD: str = "start".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.callback_query(F.data.lower() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /start""" + await state.clear() + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=RpValue.INFO_URL)) + ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'), + InlineKeyboardButton(text="Анкета📖", callback_data='anketa')) + ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin')) + + # Формируем приветственное сообщение + text: str = _( + """Добро пожаловать, {name}! + +Я ваш искусственный помощник по ролевой - {rp_name}! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! + +Интересный факт: +
{fact}
+""" + ).format( + url=message.from_user.url if message.from_user else "", + name=message.from_user.first_name if message.from_user else "пользователь", + rp_name=RpValue.RP_NAME, + fact=interesting_fact(), + ) + + # Отправляем сообщение + await msg_photo(message=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb) diff --git a/bot/handlers/messages/__init__.py b/bot/handlers/messages/__init__.py new file mode 100644 index 0000000..28ad3e4 --- /dev/null +++ b/bot/handlers/messages/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router +from .default import router as default_message_router + +# Настройка экспорта и роутера +__all__ = ('router',) +router: Router = Router(name=__name__) + +# Подготовка роутера команд +#router.include_routers( +#) + +# Подключение стандартного роутера +router.include_router(default_message_router) diff --git a/bot/handlers/messages/default.py b/bot/handlers/messages/default.py new file mode 100644 index 0000000..19572d4 --- /dev/null +++ b/bot/handlers/messages/default.py @@ -0,0 +1,15 @@ +from aiogram import Router +from aiogram.types import Message, CallbackQuery + +from bot.utils import type_msg +from middleware.loggers import loggers + +# Настройки экспорта и роутера +__all__ = ("router",) +CMD: str = "msg" +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.message() +async def default_messages(message: Message | CallbackQuery) -> None: + """Обработчик всех необработанных сообщений.""" diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..fe147a9 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1,2 @@ +from .reply import * +from .inline import * diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py new file mode 100644 index 0000000..ca2e2a9 --- /dev/null +++ b/bot/keyboards/inline/__init__.py @@ -0,0 +1 @@ +from .decision import * diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py new file mode 100644 index 0000000..05d39bd --- /dev/null +++ b/bot/keyboards/inline/decision.py @@ -0,0 +1,17 @@ +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup: + """ + Получение клавиатуры Принятия\Отклонить. + + :param thread_id: Айди действия. + :param kind: Вид для клавиатуры. + :return: Инлайн-клавиатуру (Принять, Отклонить). + """ + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row( + InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"), + InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}") + ) + return ikb.as_markup() diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..5e00bc1 --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1,47 @@ +from aiogram import Dispatcher + +from configs import ImportantID +from .logging_mdw import LoggingMiddleware +from .msg_mdw import MessageCounterMiddleware +from .spam_mdw import RateLimitMiddleware +from .subscription_mdw import SubscriptionMiddleware +from .error_mdw import ErrorHandlingMiddleware +from .time_mdw import TimingMiddleware + +__all__ = [ + LoggingMiddleware, + SubscriptionMiddleware, + RateLimitMiddleware, + ErrorHandlingMiddleware, + TimingMiddleware, + MessageCounterMiddleware, +] + + +def setup_middlewares(dp: Dispatcher, bot, channel_ids: list[int | str] = None) -> None: + """ + Регистрирует все middleware в диспетчере. + """ + channel_ids = channel_ids or [] + + # Middleware для ВСЕХ событий (update level) + middlewares_updates: list = [ + TimingMiddleware(), # Замер времени + LoggingMiddleware(), # Логирование + ErrorHandlingMiddleware(admin_ids=ImportantID.ADMIN_ID), # Обработка ошибок + ] + + # Middleware только для СООБЩЕНИЙ (message level) + middlewares_msg: list = [ + #RateLimitMiddleware(rate_limit=3, time_period=5.0), # Антифлуд + #SubscriptionMiddleware(bot=bot, channel_ids=channel_ids), # Проверка подписки + MessageCounterMiddleware(), # Подсчет сообщений + ] + + # Регистрируем middleware для всех событий + for middleware in middlewares_updates: + dp.update.middleware(middleware) + + # Регистрируем middleware только для сообщений + for middleware in middlewares_msg: + dp.message.middleware(middleware) diff --git a/bot/middlewares/error_mdw.py b/bot/middlewares/error_mdw.py new file mode 100644 index 0000000..d122b21 --- /dev/null +++ b/bot/middlewares/error_mdw.py @@ -0,0 +1,201 @@ +from typing import Callable, Awaitable, Any, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery, Update + +from middleware.loggers import loggers # ваш логгер + + +class ErrorHandlingMiddleware(BaseMiddleware): + """ + Middleware для глобальной обработки ошибок в хендлерах. + + Зачем нужен: + - Централизованная обработка исключений + - Уведомление администраторов об ошибках + - Graceful degradation при сбоях + """ + + def __init__(self, admin_ids: list[int]): + """ + Инициализация middleware обработки ошибок. + + Args: + admin_ids: Список ID администраторов для уведомлений + """ + self.admin_ids = admin_ids + super().__init__() + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """ + Перехватывает и обрабатывает ошибки в хендлерах. + """ + try: + return await handler(event, data) + + except Exception as e: + # Получаем информацию о пользователе безопасным способом + user_str = self._extract_user_info(event) + + # Логируем ошибку + error_message = f"Ошибка в хендлере: {type(e).__name__}: {str(e)}" + + loggers.error( + text=error_message, + log_type="HANDLER_ERROR", + user=user_str + ) + + # Уведомляем администраторов + await self._notify_admins(error_message, event, user_str) + + # Отправляем пользователю сообщение об ошибке + await self._send_error_message(event, user_str) + + return None + + @staticmethod + def _extract_user_info(event: TelegramObject) -> str: + """ + Безопасно извлекает информацию о пользователе из события. + + Args: + event: Объект события + + Returns: + Строка с идентификатором пользователя + """ + user_str = "@System" + + # Для Message и CallbackQuery + if isinstance(event, (Message, CallbackQuery)) and hasattr(event, 'from_user') and event.from_user: + user = event.from_user + user_str = f"@{user.username}" if user.username else f"id{user.id}" + + # Для Update (который содержит message или callback_query) + elif isinstance(event, Update): + # Пытаемся найти пользователя в различных полях Update + user_object = None + if event.message and event.message.from_user: + user_object = event.message.from_user + elif event.edited_message and event.edited_message.from_user: + user_object = event.edited_message.from_user + elif event.callback_query and event.callback_query.from_user: + user_object = event.callback_query.from_user + elif event.channel_post and event.channel_post.from_user: + user_object = event.channel_post.from_user + elif event.edited_channel_post and event.edited_channel_post.from_user: + user_object = event.edited_channel_post.from_user + + if user_object: + user_str = f"@{user_object.username}" if user_object.username else f"id{user_object.id}" + + return user_str + + @staticmethod + def _extract_event_text(event: TelegramObject) -> str: + """ + Безопасно извлекает текст из события. + + Args: + event: Объект события + + Returns: + Текст события или пустая строка + """ + event_text = "" + + # Для Message + if isinstance(event, Message) and hasattr(event, 'text') and event.text: + event_text = event.text + # Для CallbackQuery + elif isinstance(event, CallbackQuery) and hasattr(event, 'data') and event.data: + event_text = f"callback: {event.data}" + # Для Update + elif isinstance(event, Update): + if event.message and event.message.text: + event_text = event.message.text + elif event.callback_query and event.callback_query.data: + event_text = f"callback: {event.callback_query.data}" + elif event.edited_message and event.edited_message.text: + event_text = event.edited_message.text + + return event_text[:100] + "..." if len(event_text) > 100 else event_text + + async def _notify_admins( + self, + error_message: str, + event: TelegramObject, + user_str: str + ) -> None: + """Уведомляет администраторов об ошибке.""" + from aiogram import Bot + bot: Bot = event.bot if hasattr(event, 'bot') else None + + if bot: + for admin_id in self.admin_ids: + try: + event_info = f"Событие: {type(event).__name__}" + event_text = self._extract_event_text(event) + if event_text: + event_info += f", текст: {event_text}" + + full_message = ( + f"🚨 Ошибка в боте:\n\n" + f"Пользователь: {user_str}\n" + f"Ошибка: {error_message}\n" + f"{event_info}" + ) + + await bot.send_message(admin_id, full_message) + + loggers.info( + text=f"Администратор {admin_id} уведомлен об ошибке", + log_type="ADMIN_NOTIFIED", + user=user_str + ) + + except Exception as e: + loggers.error( + text=f"Не удалось уведомить админа {admin_id}: {e}", + log_type="ADMIN_NOTIFY_ERROR", + user=user_str + ) + + @staticmethod + async def _send_error_message( + event: TelegramObject, + user_str: str + ) -> None: + """Отправляет пользователю сообщение об ошибке.""" + error_text = ( + "⚠️ Произошла непредвиденная ошибка. " + "Разработчики уже уведомлены и работают над исправлением.\n\n" + "Попробуйте повторить действие позже или нажмите /start" + ) + + try: + if isinstance(event, Message): + await event.answer(error_text) + elif isinstance(event, CallbackQuery): + await event.message.answer(error_text) + await event.answer() + elif isinstance(event, Update) and event.message: + await event.message.answer(error_text) + + loggers.info( + text="Пользователю отправлено сообщение об ошибке", + log_type="ERROR_MESSAGE_SENT", + user=user_str + ) + + except Exception as e: + loggers.error( + text=f"Не удалось отправить сообщение об ошибке: {e}", + log_type="ERROR_MESSAGE_FAILED", + user=user_str + ) \ No newline at end of file diff --git a/bot/middlewares/logging_mdw.py b/bot/middlewares/logging_mdw.py new file mode 100644 index 0000000..a607fee --- /dev/null +++ b/bot/middlewares/logging_mdw.py @@ -0,0 +1,271 @@ +from typing import Callable, Awaitable, Any, Dict, Optional, Tuple, Set +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update, Message, CallbackQuery, MaybeInaccessibleMessageUnion, User + +from bot.utils import type_msg +from middleware.loggers import loggers # ваш глобальный логгер +from configs import BotSettings, COMMANDS # импортируем настройки и команды + + +class LoggingMiddleware(BaseMiddleware): + """ + Middleware для логирования апдейтов с определением типа события, + пользователя и добавлением префикса проекта к типу лога. + + Автоматически добавляет префикс проекта (например, 'PRIMO-') к типам логов: + - PRIMO-UPDATE: общий апдейт без определенного типа + - PRIMO-MSG: текстовое сообщение от пользователя + - PRIMO-CMD: команда (сообщение, начинающееся с любого префикса) + - PRIMO-CBD: callback query от инлайн-кнопок + """ + + # Префикс проекта для логов + PROJECT_PREFIX: str = "PRIMO" + + # Кэш для всех команд из COMMANDS + _all_commands: Optional[Set[str]] = None + + def __init__(self): + super().__init__() + # Предварительно загружаем все команды + self._load_all_commands() + + def _load_all_commands(self) -> None: + """Загружает все команды из COMMANDS в множество для быстрого поиска.""" + if self._all_commands is None: + self._all_commands = set() + for command_list in COMMANDS.values(): + self._all_commands.update(command_list) + + def _is_command(self, text: str) -> bool: + """ + Проверяет, является ли текст командой с любым префиксом. + + Args: + text: Текст для проверки + + Returns: + True если это команда, False если нет + """ + if not text: + return False + + # Проверяем все префиксы из BotSettings + for prefix in BotSettings.PREFIX: + if text.startswith(prefix): + # Извлекаем команду без префикса + command_without_prefix = text[len(prefix):].strip() + # Проверяем, есть ли такая команда в нашем списке + if command_without_prefix in self._all_commands: + return True + + # Также проверяем команды с префиксом / (стандартные) + if text.startswith('/'): + command_without_slash = text[1:].strip() + if command_without_slash in self._all_commands: + return True + + return False + + @staticmethod + def _extract_command_name(text: str) -> str: + """ + Извлекает название команды из текста. + + Args: + text: Текст команды с префиксом + + Returns: + Название команды без префикса + """ + for prefix in BotSettings.PREFIX: + if text.startswith(prefix): + return text[len(prefix):].strip() + + if text.startswith('/'): + return text[1:].strip() + + return text + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """ + Обрабатывает входящее событие, определяет его тип, логирует с префиксом проекта + и передает следующему обработчику. + + Args: + handler: Следующий обработчик в цепочке middleware + event: Входящее событие для обработки (Update, Message, CallbackQuery) + data: Словарь с контекстными данными FSM + + Returns: + Результат выполнения следующего обработчика + + Raises: + Exception: Любое исключение, возникшее при обработке хендлером + """ + # Определяем тип события и информацию для логирования + log_type: str + log_text: str + message_obj: Optional[Message] + + log_type, log_text, message_obj = self._determine_event_type(event) + + # Добавляем префикс проекта к типу лога + prefixed_log_type: str = f"{log_type}" + + # Определяем информацию о пользователе + user_str: str = self._extract_user_info(event, message_obj) + + # Логируем получение события с префиксом проекта + loggers.info( + text=log_text, + log_type=prefixed_log_type, + user=user_str + ) + + try: + # Передаем событие следующему обработчику + result: Any = await handler(event, data) + + # Логируем успешное выполнение для команд + if log_type == "CMD": + loggers.info( + text=f"[SUCCESS] команда обработана", + log_type=prefixed_log_type, + user=user_str + ) + + return result + + except Exception as e: + # Логируем ошибку при обработке с префиксом проекта + loggers.error( + text=f"Ошибка обработки: {str(e)}", + log_type=prefixed_log_type, + user=user_str + ) + raise + + def _determine_event_type( + self, + event: TelegramObject + ) -> Tuple[str, str, Optional[Message]]: + """ + Определяет тип события и извлекает информацию для логирования. + + Args: + event: Объект события для анализа + + Returns: + Кортеж из (тип_лога, текст_лога, объект_сообщения) + """ + log_type: str = "UPDATE" + log_text: str = f"Получен апдейт: {type(event).__name__}" + message_obj: Optional[Message] = None + + # Обработка Update объектов (основной тип в middleware) + if isinstance(event, Update): + # Пытаемся найти сообщение в различных полях Update + message_obj = ( + event.message or + event.edited_message or + event.channel_post or + event.edited_channel_post + ) + + if message_obj and message_obj.text: + if self._is_command(message_obj.text): + log_type: str = "CMD" + log_text: str = f"использовал команду '{message_obj.text}'" + else: + log_type: str = "MSG" + log_text: str = f"получено сообщение: {message_obj.text!r}" + elif message_obj: + # Не текстовое сообщение (фото, видео и т.д.) + log_type: str = "MSG" + log_text: str = f"получено сообщение: '{type_msg(message_obj)}'" + elif event.callback_query: + # Обработка callback query + callback: CallbackQuery = event.callback_query + log_type: str = "CBD" + log_text: str = f"получен callback: {callback.data!r}" + if callback.message: + message_obj: Optional[MaybeInaccessibleMessageUnion] = callback.message + + # Прямая обработка Message (если мидлварь зарегистрирован на messages) + elif isinstance(event, Message): + message_obj = event + if event.text and self._is_command(event.text): + log_type: str = "CMD" + log_text: str = f"использовал команду '{event.text}'" + elif event.text: + log_type: str = "MSG" + log_text: str = f"получено сообщение: {event.text!r}" + else: + log_type: str = "MSG" + log_text: str = f"получено сообщение типа: {event.content_type}" + + # Прямая обработка CallbackQuery (если мидлварь зарегистрирован на callbacks) + elif isinstance(event, CallbackQuery): + log_type: str = "CBD" + log_text: str = f"получен callback: {event.data!r}" + if event.message: + message_obj = event.message + + return log_type, log_text, message_obj + + @staticmethod + def _extract_user_info( + event: TelegramObject, + message: Optional[Message] = None + ) -> str: + """ + Извлекает информацию о пользователе из события. + + Args: + event: Объект события (Update, Message или CallbackQuery) + message: Объект Message (если уже определен) + + Returns: + Строка с идентификатором пользователя в формате '@username' или 'id' + """ + user_str: str = "@System" + + # Для CallbackQuery извлекаем пользователя из самого callback'а + if isinstance(event, CallbackQuery) and hasattr(event, 'from_user') and event.from_user: + user = event.from_user + user_str: str = f"@{user.username}" if user.username else f"id{user.id}" + + # Для Message извлекаем пользователя из сообщения + elif isinstance(event, Message) and hasattr(event, 'from_user') and event.from_user: + user = event.from_user + user_str: str = f"@{user.username}" if user.username else f"id{user.id}" + + # Для Update с callback_query + elif (isinstance(event, Update) and + event.callback_query and + hasattr(event.callback_query, 'from_user') and + event.callback_query.from_user): + user = event.callback_query.from_user + user_str: str = f"@{user.username}" if user.username else f"id{user.id}" + + # Для Update с сообщением + elif (isinstance(event, Update) and + (event.message or event.edited_message) and + hasattr(event.message or event.edited_message, 'from_user')): + msg = event.message or event.edited_message + if msg and msg.from_user: + user: Optional[User] = msg.from_user + user_str: str = f"@{user.username}" if user.username else f"id{user.id}" + + # Если передан message объект + elif message and hasattr(message, 'from_user') and message.from_user: + user: Optional[User] = message.from_user + user_str: str = f"@{user.username}" if user.username else f"id{user.id}" + + return user_str \ No newline at end of file diff --git a/bot/middlewares/msg_mdw.py b/bot/middlewares/msg_mdw.py new file mode 100644 index 0000000..1feee35 --- /dev/null +++ b/bot/middlewares/msg_mdw.py @@ -0,0 +1,55 @@ +import logging +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.enums import ChatType +from aiogram.types import Message +from database import db + +logger = logging.getLogger(__name__) + +class MessageCounterMiddleware(BaseMiddleware): + """ + Middleware для подсчёта сообщений в группах и супергруппах. + """ + + async def __call__( + self, + handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], + event: Any, + data: Dict[str, Any] + ) -> Any: + if not isinstance(event, Message): + return await handler(event, data) + + # Проверяем, что сообщение пришло из группового чата и не от бота + if (event.chat.type in (ChatType.GROUP, ChatType.SUPERGROUP) and + not event.from_user.is_bot): + try: + await self.process_group_message(event) + except Exception as e: + logger.error(msg=f"Ошибка при обработке сообщения: {e}", exc_info=True) + + return await handler(event, data) + + @staticmethod + async def process_group_message(message: Message) -> None: + """ + Обработка сообщения из группового чата. + """ + user_id = message.from_user.id + message_text = message.text or message.caption or "" + + # Добавляем пользователя (если его ещё нет) + await db.add_user( + user_id=user_id, + username=message.from_user.username, + full_name=message.from_user.full_name, + ) + + # Сохраняем сообщение + await db.add_message( + user_id=user_id, + message_text=message_text, + created_at=message.date, + ) + logger.info(f"Сообщение от пользователя {user_id} сохранено в БД") diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py new file mode 100644 index 0000000..cc6c509 --- /dev/null +++ b/bot/middlewares/spam_mdw.py @@ -0,0 +1,97 @@ +from typing import Callable, Awaitable, Any, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery +import time +from collections import defaultdict + +from middleware.loggers import loggers # ваш логгер + + +class RateLimitMiddleware(BaseMiddleware): + """ + Middleware для ограничения частоты запросов от пользователей (анти-спам). + + Зачем нужен: + - Защита от DDoS и флуда + - Предотвращение злоупотребления ботом + - Контроль нагрузки на сервер + """ + + def __init__(self, rate_limit: int = 10, time_period: float = 2.0): + """ + Инициализация rate limit middleware. + + Args: + rate_limit: Максимальное количество запросов за период + time_period: Период времени в секундах + """ + self.rate_limit = rate_limit + self.time_period = time_period + self.user_calls: Dict[int, list[float]] = defaultdict(list) + super().__init__() + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + log: bool = False, + ) -> Any: + """ + Проверяет rate limit перед обработкой запроса. + """ + # Пропускаем не-сообщения и не-колбэки + if not isinstance(event, (Message, CallbackQuery)): + return await handler(event, data) + + user_id: int = event.from_user.id + user_str: str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}" + current_time: float = time.time() + + # Очищаем старые запросы + self.user_calls[user_id] = [ + call_time for call_time in self.user_calls[user_id] + if current_time - call_time < self.time_period + ] + + # Логируем текущее состояние rate limit + if log: + loggers.debug( + text=f"Rate limit: {len(self.user_calls[user_id])}/{self.rate_limit} за {self.time_period}сек", + log_type="RATE_LIMIT_STATUS", + user=user_str + ) + + # Проверяем текущий лимит + if len(self.user_calls[user_id]) >= self.rate_limit: + # Логируем попытку спама + if log: + loggers.warning( + text=f"Превышен rate limit ({self.rate_limit}/{self.time_period}сек)", + log_type="RATE_LIMIT_EXCEEDED", + user=user_str + ) + + # Отправляем сообщение о превышении лимита + if isinstance(event, Message): + await event.answer( + text="⏳ Слишком много запросов! Пожалуйста, подождите немного.", + ) + elif isinstance(event, CallbackQuery): + await event.answer( + text="⏳ Подождите немного перед следующим действием.", + show_alert=True + ) + + return None + + # Добавляем текущий запрос и продолжаем обработку + self.user_calls[user_id].append(current_time) + + loggers.debug( + text=f"Запрос добавлен в rate limit", + log_type="RATE_LIMIT_ADDED", + user=user_str + ) + + return await handler(event, data) \ No newline at end of file diff --git a/bot/middlewares/subscription_mdw.py b/bot/middlewares/subscription_mdw.py new file mode 100644 index 0000000..66d8b2d --- /dev/null +++ b/bot/middlewares/subscription_mdw.py @@ -0,0 +1,115 @@ +from typing import Callable, Awaitable, Any, Dict +from aiogram import BaseMiddleware, Bot +from aiogram.types import TelegramObject, Message, CallbackQuery +from aiogram.exceptions import TelegramBadRequest + +from middleware.loggers import loggers # ваш логгер + + +class SubscriptionMiddleware(BaseMiddleware): + """ + Middleware для проверки подписки пользователя на необходимые каналы. + Блокирует обработку команд, если пользователь не подписан. + + Зачем нужен: + - Автоматическая проверка подписки для всех входящих сообщений + - Единая точка управления подписками + - Предотвращение доступа к функционалу без подписки + """ + + def __init__(self, bot: Bot, channel_ids: list[int | str]): + """ + Инициализация middleware проверки подписки. + + Args: + bot: Экземпляр бота + channel_ids: Список ID каналов/чатов для проверки подписки + """ + self.bot = bot + self.channel_ids = channel_ids + super().__init__() + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """ + Проверяет подписку пользователя перед обработкой команды. + """ + # Пропускаем не-сообщения и не-колбэки + if not isinstance(event, (Message, CallbackQuery)): + return await handler(event, data) + + user_id: int = event.from_user.id + user_str: str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}" + + # Логируем начало проверки подписки + loggers.info( + text=f"Проверка подписки для пользователя", + log_type="SUBSCRIPTION_CHECK", + user=user_str + ) + + # Проверяем подписку на все required каналы + not_subscribed_channels: list[str] = [] + + for channel_id in self.channel_ids: + try: + member = await self.bot.get_chat_member( + chat_id=channel_id, + user_id=user_id + ) + # Проверяем, что пользователь является участником + if member.status not in ['member', 'administrator', 'creator']: + not_subscribed_channels.append(str(channel_id)) + + except TelegramBadRequest as e: + loggers.error( + text=f"Ошибка проверки подписки на канал {channel_id}: {e}", + log_type="SUBSCRIPTION_ERROR", + user=user_str + ) + + # Если пользователь не подписан на некоторые каналы + if not_subscribed_channels: + loggers.warning( + text=f"Пользователь не подписан на каналы: {', '.join(not_subscribed_channels)}", + log_type="SUBSCRIPTION_FAILED", + user=user_str + ) + + warning_text = ( + "📢 Для использования бота необходимо подписаться на наши каналы!\n\n" + "После подписки нажмите /start для продолжения." + ) + + # Создаем кнопку "Проверить подписку" + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[ + InlineKeyboardButton( + text="✅ Я подписался", + callback_data="check_subscription" + ) + ]] + ) + + if isinstance(event, Message): + await event.answer(warning_text, reply_markup=keyboard) + elif isinstance(event, CallbackQuery): + await event.message.answer(warning_text, reply_markup=keyboard) + await event.answer() + + return None + + # Логируем успешную проверку подписки + loggers.info( + text="Пользователь подписан на все required каналы", + log_type="SUBSCRIPTION_SUCCESS", + user=user_str + ) + + # Если подписка есть, продолжаем обработку + return await handler(event, data) \ No newline at end of file diff --git a/bot/middlewares/time_mdw.py b/bot/middlewares/time_mdw.py new file mode 100644 index 0000000..85d85d4 --- /dev/null +++ b/bot/middlewares/time_mdw.py @@ -0,0 +1,82 @@ +from typing import Callable, Awaitable, Any, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery, Update +from time import time + +from middleware.loggers import loggers # ваш логгер + + +class TimingMiddleware(BaseMiddleware): + """ + Middleware для измерения времени выполнения хендлеров. + + Зачем нужен: + - Мониторинг производительности хендлеров + - Выявление медленных запросов + - Оптимизация кода бота + """ + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + perm: str = None, + ) -> Any: + """ + Измеряет время выполнения хендлера. + """ + start_time: float = time() + + try: + result = await handler(event, data) + return result + + finally: + execution_time: float = time() - start_time + + # Получаем информацию о пользователе безопасным способом + user_str: str = "@System" + + # Для Message и CallbackQuery + if isinstance(event, (Message, CallbackQuery)) and hasattr(event, 'from_user') and event.from_user: + user = event.from_user + user_str = f"@{user.username}" if user.username else f"id{user.id}" + + # Для Update (который содержит message или callback_query) + elif isinstance(event, Update): + # Пытаемся найти пользователя в различных полях Update + user_object = None + if event.message and event.message.from_user: + user_object = event.message.from_user + elif event.edited_message and event.edited_message.from_user: + user_object = event.edited_message.from_user + elif event.callback_query and event.callback_query.from_user: + user_object = event.callback_query.from_user + elif event.channel_post and event.channel_post.from_user: + user_object = event.channel_post.from_user + elif event.edited_channel_post and event.edited_channel_post.from_user: + user_object = event.edited_channel_post.from_user + + if user_object: + user_str = f"@{user_object.username}" if user_object.username else f"id{user_object.id}" + + # Логируем время выполнения + if execution_time > 1.0 and perm: # Медленные запросы + loggers.warning( + text=f"Медленный хендлер: {execution_time:.2f}сек", + log_type="SLOW_HANDLER", + user=user_str + ) + elif execution_time > 0.5 and perm == "medium": # Средние запросы + loggers.info( + text=f"Среднее время выполнения: {execution_time:.3f}сек", + log_type="HANDLER_TIMING", + user=user_str + ) + elif perm == "fast": # Быстрые запросы + loggers.debug( + text=f"Быстрое выполнение: {execution_time:.3f}сек", + log_type="HANDLER_TIMING_FAST", + user=user_str + ) \ No newline at end of file diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/states/anketa_states.py b/bot/states/anketa_states.py new file mode 100644 index 0000000..c3ba68b --- /dev/null +++ b/bot/states/anketa_states.py @@ -0,0 +1,5 @@ +# bot/states/form.py +from aiogram.fsm.state import State, StatesGroup + +class StartForm(StatesGroup): + waiting_for_application: State = State() diff --git a/bot/states/new_states.py b/bot/states/new_states.py new file mode 100644 index 0000000..f9b5fcf --- /dev/null +++ b/bot/states/new_states.py @@ -0,0 +1,8 @@ +# bot/states/new_states.py +from aiogram.fsm.state import State, StatesGroup + +class NewStates(StatesGroup): + role: State = State() + sorol: State = State() + code_phrase: State = State() + rules: State = State() diff --git a/bot/templates/__init__.py b/bot/templates/__init__.py new file mode 100644 index 0000000..b969334 --- /dev/null +++ b/bot/templates/__init__.py @@ -0,0 +1 @@ +from .message_callback import * diff --git a/bot/templates/message_callback.py b/bot/templates/message_callback.py new file mode 100644 index 0000000..917ebc1 --- /dev/null +++ b/bot/templates/message_callback.py @@ -0,0 +1,77 @@ +from typing import Union + +from aiogram.types import FSInputFile, CallbackQuery, Message, ReplyKeyboardMarkup, InlineKeyboardMarkup +from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder + +# Настройка экспорта +__all__ = ('msg', 'msg_photo') + + +async def msg(message: Message | CallbackQuery, + text: str = "Сообщение отправлено!", + markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None) -> None: + """ + Шаблон для ответа на сообщение текстом. + :param message: Объект сообщения или callback-запроса. + :param text: Текст отправного сообщения от бота. + :param markup: Кнопки сообщения (инлайн или реплай). + """ + + # Преобразуем клавиатуру + reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None + if markup: + if isinstance(markup, InlineKeyboardBuilder): + reply_markup: InlineKeyboardMarkup = markup.as_markup() + elif isinstance(markup, ReplyKeyboardBuilder): + reply_markup: ReplyKeyboardMarkup = markup.as_markup(resize_keyboard=True) + + # Обработчик ответа на сообщение + if isinstance(message, Message): + await message.reply( + text=text, + reply_markup=reply_markup + ) + # Обработчик ответа на callback + else: + await message.message.reply( + text=text, + reply_markup=reply_markup + ) + + +async def msg_photo( + message: Message | CallbackQuery, + text: str = "Сообщение отправлено!", + file: str = "assets/default.jpg", + markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None) -> None: + """ + Шаблон для ответа на сообщение фотографией. + :param message: Объект сообщения или callback-запроса. + :param file: Путь к фотографии для ответа. + :param text: Подпись к фото. + :param markup: Кнопки сообщения (инлайн или реплай). + """ + + # Преобразуем клавиатуру + reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None + if markup: + if isinstance(markup, InlineKeyboardBuilder): + reply_markup = markup.as_markup() + elif isinstance(markup, ReplyKeyboardBuilder): + reply_markup = markup.as_markup(resize_keyboard=True) + + # Обработчик ответа на сообщение + if isinstance(message, Message): + await message.reply_photo( + photo=FSInputFile(file), + caption=text, + reply_markup=reply_markup + ) + + # Обработчик ответа на callback + else: + await message.message.reply_photo( + photo=FSInputFile(file), + caption=text, + reply_markup=reply_markup + ) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..0fb8d4c --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,5 @@ +from .interesting_facts import * +from .usernames import * +from .pagination import * +from .type_message import * +from .argument import * diff --git a/bot/utils/argument.py b/bot/utils/argument.py new file mode 100644 index 0000000..3624932 --- /dev/null +++ b/bot/utils/argument.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Optional +from configs import BotSettings + +__all__ = ("is_command", "find_argument") + + +def is_command(message: Optional[str]) -> bool: + """ + Проверяет, является ли сообщение командой. + + Сообщение считается командой, если: + 1. Оно не пустое; + 2. Начинается с префикса команды, указанного в настройках. + + Args: + message (Optional[str]): Входное сообщение. + + Returns: + bool: True, если сообщение является командой, иначе False. + + Пример: + >>> is_command("/start") + True + >>> is_command("hello") + False + """ + if not message: + return False + return message.strip().startswith(BotSettings.PREFIX) + + +def find_argument(message: Optional[str]) -> Optional[str]: + """ + Извлекает аргумент команды из сообщения. + + Аргументом считается текст после первой команды и пробела. + Если аргумента нет — возвращает None. + + Args: + message (Optional[str]): Входное сообщение. + + Returns: + Optional[str]: Аргумент команды или None, если его нет. + + Пример: + >>> find_argument("/start referrer") + 'referrer' + >>> find_argument("/start") + None + >>> find_argument("hello") + None + """ + if not is_command(message): + return None + + parts = message.strip().split(maxsplit=1) + return parts[1] if len(parts) > 1 else None diff --git a/bot/utils/interesting_facts.py b/bot/utils/interesting_facts.py new file mode 100644 index 0000000..5a799c0 --- /dev/null +++ b/bot/utils/interesting_facts.py @@ -0,0 +1,29 @@ +from random import choice + +from configs.config import Lists + +# Настройки экспорта +__all__ = ("interesting_fact",) + + +def interesting_fact(mode: str = "факт", lists: list[str] = None) -> str: + """ + Возвращает случайный факт, анекдот или цитату, в зависимости от режима. + + :param mode: Строка, определяющая тип контента ("факт", "анекдот", "цитата"). + :param lists: Необязательный список строк, из которого можно выбирать вручную. + :return: Случайный элемент из соответствующего списка. + """ + if lists is not None: + return choice(lists) + + mode: str = mode.lower() + + if mode == "анекдот": + source: list[str] = Lists.jokes + elif mode == "цитата": + source: list[str] = Lists.quotes + else: + source: list[str] = Lists.facts + + return choice(source) diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py new file mode 100644 index 0000000..ff93cbf --- /dev/null +++ b/bot/utils/pagination.py @@ -0,0 +1,28 @@ +from aiogram.types import InlineKeyboardButton + +# Настройка экспорта в модули +__all__ = ('pagination_btn',) + +def pagination_btn(action: str, + page: int = 0, + total_posts: int = 0, + bt_page: int = 5) -> list[InlineKeyboardButton]: + """ + Создает кнопки для пагинации. + + :param action: Действие в котором нужна пангинация. + :param page: Номер начальной страницы, по умолчанию 0. + :param total_posts: Количество постов. + :param bt_page: Количество кнопок на одной странице. + :return: Готовый лист списка инлайн-кнопок. + """ + navigation_buttons: list[InlineKeyboardButton] = [] + if page > 0: + navigation_buttons.append(InlineKeyboardButton( + text="←", callback_data=f"{action}_page_{page - 1}" + )) + if (page + 1) * bt_page < total_posts: + navigation_buttons.append(InlineKeyboardButton( + text="→", callback_data=f"{action}_page_{page + 1}" + )) + return navigation_buttons diff --git a/bot/utils/type_message.py b/bot/utils/type_message.py new file mode 100644 index 0000000..eab769e --- /dev/null +++ b/bot/utils/type_message.py @@ -0,0 +1,85 @@ +from typing import Final + +from aiogram.types import Message + +# Настройка экспорта +__all__ = ("CHAT_TYPES", "CONTENT_TYPE_RU", "type_chat", "type_msg") + + +# Словарь сопоставлений "chat_type -> русское название" +CHAT_TYPES: Final[dict[str, str]] = { + "private": "Личный", + "group": "Группа", + "supergroup": "Группа", + "channel": "Канал", + } + +# Словарь сопоставлений "content_type -> русское название" +CONTENT_TYPE_RU: Final[dict[str, str]] = { + "text": "Текст", + "animation": "Гиф", + "audio": "Аудио", + "document": "Файл", + "photo": "Фото", + "sticker": "Стикер", + "video": "Видео", + "video_note": "Видеосообщение", + "voice": "Голосовое сообщение", + "contact": "Контакт", + "dice": "Кубик", + "game": "Игра", + "poll": "Опрос", + "venue": "Место", + "location": "Локация", + "new_chat_members": "Новые участники чата", + "left_chat_member": "Участник вышел", + "new_chat_title": "Новое название чата", + "new_chat_photo": "Новая картинка чата", + "delete_chat_photo": "Удалена картинка чата", + "group_chat_created": "Создана группа", + "supergroup_chat_created": "Создана супергруппа", + "channel_chat_created": "Создан канал", + "message_auto_delete_timer_changed": "Изменён автоудалитель", + "migrate_to_chat_id": "Группа → супергруппа", + "migrate_from_chat_id": "Супергруппа → группа", + "pinned_message": "Закреплённое сообщение", + "invoice": "Счёт", + "successful_payment": "Успешный платёж", + "connected_website": "Подключённый сайт", + "passport_data": "Данные Telegram Passport", + "proximity_alert_triggered": "Алерт о приближении", + "video_chat_scheduled": "Запланированный видеочат", + "video_chat_started": "Видеочат начался", + "video_chat_ended": "Видеочат завершён", + "video_chat_participants_invited": "Приглашены участники видеочата", + "web_app_data": "Данные из веб-приложения", + "forum_topic_created": "Создана тема форума", + "forum_topic_edited": "Изменена тема форума", + "forum_topic_closed": "Тема форума закрыта", + "forum_topic_reopened": "Тема форума открыта", + "general_forum_topic_hidden": "Общая тема скрыта", + "general_forum_topic_unhidden": "Общая тема снова отображается", + "giveaway_created": "Создан розыгрыш", + "giveaway": "Розыгрыш", + "giveaway_completed": "Розыгрыш завершён", + "message_reaction": "Реакция на сообщение", +} + + +def type_msg(message: Message) -> str: + """ + Определяет и возвращает тип сообщения на русском языке. + + :param message: объект Message от aiogram + :return: строка с типом сообщения + """ + return CONTENT_TYPE_RU.get(message.content_type, f"Неизвестный тип ({message.content_type})") + +def type_chat(message: Message) -> str: + """ + Преобразует информацию о чате в его тип на русском языке. + + :param message: Объект сообщения из aiogram, содержащий информацию о чате. + :return: Тип чата строкой. + """ + return CHAT_TYPES.get(message.chat.type, f"Неизвестный тип чата {message.chat.type}") diff --git a/bot/utils/usernames.py b/bot/utils/usernames.py new file mode 100644 index 0000000..ad71431 --- /dev/null +++ b/bot/utils/usernames.py @@ -0,0 +1,21 @@ +from aiogram.types import Message + +# Настройка экспорта в модули +__all__ = ('username', ) + +# Функция получения юзера или ID пользователя +def username(message: Message) -> str: + """ + Возвращает юзернейм пользователя из сообщения, или ID, если юзернейм не указан. + + :param message: Объект сообщения из aiogram. + :return: Строка с юзернеймом пользователя или его ID. + :raises ValueError: Если в сообщении отсутствует информация о пользователе. + """ + try: + if message.from_user: + return f"@{message.from_user.username}" if message.from_user.username else f"@{message.from_user.id}" + raise ValueError("Информация о пользователе отсутствует в сообщении.") + + except ValueError as e: + raise e # Перебрасываем ошибку выше для дальнейшей обработки diff --git a/configs/__init__.py b/configs/__init__.py new file mode 100644 index 0000000..8846c59 --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1,3 @@ +from .config import * +from .cmd_list import * +from .roles import * diff --git a/configs/cmd_list.py b/configs/cmd_list.py new file mode 100644 index 0000000..9ec6853 --- /dev/null +++ b/configs/cmd_list.py @@ -0,0 +1,86 @@ +from typing import Final + +# Список команд по ключу +COMMANDS: Final[dict[str, list[str]]] = { + "start": [ + "start", "старт", "почати", + "ыефке", "cnfhn", "on", "вкл", "щт", "drk", + ], + "help": [ + "help", "помощь", "допомога", + "рудзщь", "dopomoga", "?", + ], + "menu": [ + "menu", "меню", "менюшка", + "ьщкф", "menyu", + ], + "create": [ + "create", "создать", "створити", + "сщзду", "sozdat", "stvoriti", + ], + "report": [ + "report", "репорт", "скарга", + "кщзщтв", "repert", + ], + "mute": [ + "mute", "заглушить", "заглушити", + "угуыщцук", "zaglushit", + ], + "kick": [ + "kick", "кик", "викинути", + "куиф", "vikynuty", + ], + "ban": [ + "ban", "бан", "забанити", + "ьфд", "zabanyty", + ], + "stats": [ + "stats", "статистика", "статистика", + "ыпщз", "statystyka", + ], + "settings": [ + "settings", "настройки", "налаштування", + "гшеукефьз", "nastroyky", + ], + "info": [ + "info", "инфо", "інфо", + "шкещ", "info", + ], + "feedback": [ + "feedback", "обратная связь", "зворотній зв’язок", + "гуеекфьз", "obratnaia_svyaz", + ], + "subscribe": [ + "subscribe", "подписаться", "підписатися", + "подписатсь", "pidpysatysia", + ], + "unsubscribe": [ + "unsubscribe", "отписаться", "відписатися", + "отписаться", "vidpysatysia", + ], + "language": [ + "language", "язык", "мова", + "йцукефь", "mova", + ], + "cancel": [ + "cancel", "отмена", "скасувати", + "утпщге", "skasuvaty", + ], + "list": [ + "list", "список", "список", + "дшззщк", "spysok", + ], + "forward": [ + "forward", "переслать", "переслати", + "дшпекщву", "pereslaty", + ], + + + "new": [ + "new", "туц", "вступление", + "cnegktybt", "ym.", "нью", + ], + "active": [ + "active", + ] +} diff --git a/configs/config.py b/configs/config.py new file mode 100644 index 0000000..a1753c0 --- /dev/null +++ b/configs/config.py @@ -0,0 +1,394 @@ +from pathlib import Path +from urllib.parse import urlparse, ParseResult +from typing import ClassVar, Final, Optional, Any + +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from aiogram.types import ChatAdministratorRights + + +class Settings(BaseSettings): + """Улучшенный класс настроек с комплексной валидацией""" + + # Конфигурация загрузки переменных окружения + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + validate_default=True, + ) + + # Режимы и базовые параметры + PYTHONUNBUFFERED: str = "1" + LOCALE_PATH: str = "locales" + + DEBUG: bool = False + OWNER: str = "@verdise" + + # Токены бота + BOT_TOKEN: Optional[str] = None + BOT_DEBUG_TOKEN: Optional[str] = None + + # Параметры сообщений + PARSE_MODE: str = "HTML" + ENCOD: str = "utf-8" + TIME_FORMAT: str = "%Y-%m-%d %H:%M:%S" + PREFIX: str = "/!.&?" + BOT_LANGUAGE: str = "Aiogram3" + + # Настройки сообщений + DISABLE_NOTIFICATION: bool = False + PROTECT_CONTENT: bool = False + ALLOW_SENDING_WITHOUT_REPLY: bool = True + LINK_PREVIEW_IS_DISABLED: bool = False + LINK_PREVIEW_PREFER_SMALL_MEDIA: bool = False + LINK_PREVIEW_PREFER_LARGE_MEDIA: bool = True + LINK_PREVIEW_SHOW_ABOVE_TEXT: bool = True + SHOW_CAPTION_ABOVE_MEDIA: bool = False + + # Разрешения и логирование + BOT_EDIT: bool = True + START_INFO_CONSOLE: bool = True + START_INFO_TO_FILE: bool = True + LOG_CONSOLE: bool = True + LOG_FILE: bool = True + LOG_DIR: Path = Path('Logs') + LOG_FILE_INFO: Path = Path('bot_info.log') + + # Вебхук + WEBHOOK: bool = False + WEBHOOK_URL: str = "https://bot.primo.dpdns.org/webhook" # публичный HTTPS url + WEBAPP_HOST: str = "0.0.0.0" # адрес, на котором слушает uvicorn внутри контейнера + WEBAPP_PORT: int = 3131 + LOG_LEVEL: str = "warning" + ACCES_LOG: bool = False + + # API ключи + API_KEY: Optional[str] = None + WEB_API_KEY: Optional[str] = None + WEATHER_API_KEY: Optional[str] = None + + # Пользовательские данные + TG_API_UID: int = 0 + TG_API_HASH: Optional[str] = None + + # Идентификаторы + ADMIN_ID: list[int] = [] + MODERATOR_ID: int = 0 + IMPORTANT_ID: int = 0 + IMPORTANT_GROUP_ID: int = 0 + IMPORTANT_CHANNEL_ID: int = 0 + SUPPORT_CHAT_ID: int = 0 + + # Настройки бота + PROJECT_NAME: str = "PRIMO" + BOT_NAME: str = "Первозданная Жемчужина" + BOT_DESCRIPTION: Optional[str] = None + BOT_SHORT_DESCRIPTION: Optional[str] = None + + # Ролевой проект + RP_NAME: Optional[str] = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥" + INFO_URL: Optional[str] = "https://t.me/PrimoWorldRP" + FLUD_URL: Optional[str] = "https://t.me/PrimoWorldRP" + RP_URL: Optional[str] = "https://t.me/PrimoWorldRP" + LIFE_URL: Optional[str] = "https://t.me/PrimoWorldRP" + RP_OWNER: Optional[str] = None + ROLES: list[str] = ["Альбедо", "Чжун Ли", "Кэйа"] + + # Права администратора + ANONYMOUS: bool = False + MANAGE_CHAT: bool = True + CHANGE_INFO: bool = True + PROMOTE_MEMBERS: bool = True + RESTRICT_MEMBERS: bool = True + POST_MESSAGE: bool = True + MANAGE_TOPICS: bool = True + INVITE_USER: bool = True + DELETE_MESSAGES: bool = True + MANAGE_VIDEO_CHATS: bool = True + EDIT_MESSAGES: bool = True + PIN_MESSAGE: bool = True + POST_STORIES: bool = True + EDIT_STORIES: bool = True + DELETE_STORIES: bool = True + + # ================= ВАЛИДАТОРЫ ================= + + @field_validator('PYTHONUNBUFFERED') + def validate_unbuffered(cls, v: str) -> str: + """Проверка корректности значения буферизации""" + if v not in ('0', '1'): + raise ValueError("PYTHONUNBUFFERED должен быть '0' или '1'") + return v + + @field_validator('PARSE_MODE') + def validate_parse_mode(cls, v: str) -> str: + """Проверка допустимого режима разметки""" + allowed_modes: set[str] = {"HTML", "Markdown", "MarkdownV2"} + if v not in allowed_modes: + raise ValueError(f"Недопустимый PARSE_MODE. Допустимые значения: {', '.join(allowed_modes)}") + return v + + @field_validator('PREFIX') + def validate_prefix(cls, v: str) -> str: + """Очистка и проверка префиксов команд""" + cleaned: str = ''.join(sorted(set(v), key=v.index)) # Удаление дубликатов с сохранением порядка + if len(cleaned) < 1: + raise ValueError("PREFIX должен содержать хотя бы один символ") + return cleaned + + @field_validator('LOG_DIR', 'LOG_FILE_INFO', mode='before') + def validate_paths(cls, v: Any) -> Path: + """Преобразование путей в объекты Path""" + return Path(v) if isinstance(v, str) else v + + @field_validator('TG_API_UID', 'MODERATOR_ID') + def validate_ids(cls, v: int) -> int: + """Проверка корректности идентификаторов""" + if v < 0: + raise ValueError("ID не может быть отрицательным") + return v + + @field_validator('WEBHOOK_URL') + def validate_webhook_url(cls, v: str) -> str: + """Базовая проверка URL вебхука""" + parsed: ParseResult = urlparse(v) + if not all([parsed.scheme, parsed.netloc]): + raise ValueError("Некорректный URL вебхука") + return v + + @field_validator('BOT_NAME', 'PROJECT_NAME', 'OWNER') + def validate_non_empty(cls, v: str) -> str: + """Проверка непустых строк""" + if not v.strip(): + raise ValueError("Поле не может быть пустым") + return v + + @model_validator(mode='after') + def validate_bot_token(cls, setting: "Settings") -> "Settings": + """Проверка наличия необходимых токенов""" + if setting.DEBUG and not setting.BOT_DEBUG_TOKEN: + raise ValueError("Требуется BOT_DEBUG_TOKEN в режиме DEBUG") + if not setting.DEBUG and not setting.BOT_TOKEN: + raise ValueError("Требуется BOT_TOKEN для рабочего режима") + return setting + + @model_validator(mode='after') + def validate_webhook_config(cls, setting: "Settings") -> "Settings": + """Проверка конфигурации вебхука""" + if setting.WEBHOOK and not setting.WEBHOOK_URL: + raise ValueError("WEBHOOK_URL обязателен при включенном WEBHOOK") + return setting + + @model_validator(mode='after') + def validate_logging_paths(cls, setting: "Settings") -> "Settings": + """Создание директорий для логов при необходимости""" + if setting.LOG_FILE and not setting.LOG_DIR.exists(): + setting.LOG_DIR.mkdir(parents=True, exist_ok=True) + return setting + + @model_validator(mode='after') + def set_dynamic_descriptions(cls, setting: "Settings") -> "Settings": + """Динамическая установка описаний бота""" + if setting.BOT_DESCRIPTION is None: + setting.BOT_DESCRIPTION = f"Ваш помощник в удивительные миры! Prod. by:『{setting.OWNER}』" + if setting.BOT_SHORT_DESCRIPTION is None: + setting.BOT_SHORT_DESCRIPTION = f"Тех.поддержка: {setting.OWNER}" + return setting + + # ================= СВОЙСТВА ================= + + @property + def rights(self) -> ChatAdministratorRights: + """Права администратора бота""" + return ChatAdministratorRights( + is_anonymous=self.ANONYMOUS, + can_manage_chat=self.MANAGE_CHAT, + can_delete_messages=self.DELETE_MESSAGES, + can_manage_video_chats=self.MANAGE_VIDEO_CHATS, + can_restrict_members=self.RESTRICT_MEMBERS, + can_promote_members=self.PROMOTE_MEMBERS, + can_change_info=self.CHANGE_INFO, + can_invite_users=self.INVITE_USER, + can_post_stories=self.POST_STORIES, + can_edit_stories=self.EDIT_STORIES, + can_delete_stories=self.DELETE_STORIES, + can_post_messages=self.POST_MESSAGE, + can_edit_messages=self.EDIT_MESSAGES, + can_pin_messages=self.PIN_MESSAGE, + can_manage_topics=self.MANAGE_TOPICS, + ) + + @property + def active_bot_token(self) -> str: + """Активный токен бота в зависимости от режима""" + token = self.BOT_DEBUG_TOKEN if self.DEBUG else self.BOT_TOKEN + if not token: + raise ValueError("Активный токен бота отсутствует") + return token + + @property + def log_dir_absolute(self) -> Path: + """Абсолютный путь к директории логов""" + return self.LOG_DIR.absolute() + + +# Инициализация настроек +settings: Settings = Settings() + + +# Классы для обратной совместимости и удобства использования + +class BotSettings: + """Алиасы для настроек бота.""" + DEBUG: Final[bool] = settings.DEBUG + OWNER: Final[str] = settings.OWNER + BOT_TOKEN: Final[str] = settings.active_bot_token + PARSE_MODE: Final[str] = settings.PARSE_MODE + ENCOD: Final[str] = settings.ENCOD + TIME_FORMAT: Final[str] = settings.TIME_FORMAT + PREFIX: Final[str] = settings.PREFIX + BOT_LANGUAGE: Final[str] = settings.BOT_LANGUAGE + DISABLE_NOTIFICATION: Final[bool] = settings.DISABLE_NOTIFICATION + PROTECT_CONTENT: Final[bool] = settings.PROTECT_CONTENT + ALLOW_SENDING_WITHOUT_REPLY: Final[bool] = settings.ALLOW_SENDING_WITHOUT_REPLY + LINK_PREVIEW_IS_DISABLED: Final[bool] = settings.LINK_PREVIEW_IS_DISABLED + LINK_PREVIEW_PREFER_SMALL_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_SMALL_MEDIA + LINK_PREVIEW_PREFER_LARGE_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_LARGE_MEDIA + LINK_PREVIEW_SHOW_ABOVE_TEXT: Final[bool] = settings.LINK_PREVIEW_SHOW_ABOVE_TEXT + SHOW_CAPTION_ABOVE_MEDIA: Final[bool] = settings.SHOW_CAPTION_ABOVE_MEDIA + + +class Permission: + """Алиасы для разрешений.""" + BOT_EDIT: Final[bool] = settings.BOT_EDIT + START_INFO_CONSOLE: Final[bool] = settings.START_INFO_CONSOLE + START_INFO_TO_FILE: Final[bool] = settings.START_INFO_TO_FILE + + +class LogConfig: + """Алиасы для конфигурации логов.""" + CONSOLE: Final[bool] = settings.LOG_CONSOLE + FILE: Final[bool] = settings.LOG_FILE + DIR: Final[Path] = settings.LOG_DIR + FILE_INFO: Final[Path] = settings.LOG_FILE_INFO + ROTATION: ClassVar[str] = '100 MB' + RETENTION: ClassVar[str] = '7 days' + + +class Webhook: + """Алиасы для вебхука.""" + WEBHOOK: Final[bool] = settings.WEBHOOK + WEBHOOK_URL: Final[str] = settings.WEBHOOK_URL + WEBAPP_HOST: Final[str] = settings.WEBAPP_HOST + WEBAPP_PORT: Final[int] = settings.WEBAPP_PORT + LOG_LEVEL: Final[str] = settings.LOG_LEVEL + ACCES_LOG: Final[bool] = settings.ACCES_LOG + + + +class APISettings: + """Алиасы для API.""" + API_KEY: Final[Optional[str]] = settings.API_KEY + WEB_API_KEY: Final[Optional[str]] = settings.WEB_API_KEY + WEATHER_API_KEY: Final[Optional[str]] = settings.WEATHER_API_KEY + + +class UserIn: + """Алиасы для пользовательских данных.""" + TG_API_UID: Final[int] = settings.TG_API_UID + TG_API_HASH: Final[Optional[str]] = settings.TG_API_HASH + + +class ImportantID: + """Алиасы для важных ID.""" + ADMIN_ID: Final[list[int]] = settings.ADMIN_ID + MODERATOR_ID: Final[int] = settings.MODERATOR_ID + IMPORTANT_ID: Final[int] = settings.IMPORTANT_ID + IMPORTANT_GROUP_ID: Final[int] = settings.IMPORTANT_GROUP_ID + IMPORTANT_CHANNEL_ID: Final[int] = settings.IMPORTANT_CHANNEL_ID + SUPPORT_CHAT_ID: Final[int] = settings.SUPPORT_CHAT_ID + + +class BotEdit: + """Алиасы для настроек редактирования бота.""" + ALLOW_PERMISSION: Final[bool] = settings.BOT_EDIT + PROJECT_NAME: Final[str] = settings.PROJECT_NAME + NAME: Final[str] = settings.BOT_NAME + DESCRIPTION: Final[str] = settings.BOT_DESCRIPTION + SHORT_DESCRIPTION: Final[str] = settings.BOT_SHORT_DESCRIPTION + ANONYMOUS: Final[bool] = settings.ANONYMOUS + MANAGE_CHAT: Final[bool] = settings.MANAGE_CHAT + CHANGE_INFO: Final[bool] = settings.CHANGE_INFO + PROMOTE_MEMBERS: Final[bool] = settings.PROMOTE_MEMBERS + RESTRICT_MEMBERS: Final[bool] = settings.RESTRICT_MEMBERS + POST_MESSAGE: Final[bool] = settings.POST_MESSAGE + MANAGE_TOPICS: Final[bool] = settings.MANAGE_TOPICS + INVITE_USER: Final[bool] = settings.INVITE_USER + DELETE_MESSAGES: Final[bool] = settings.DELETE_MESSAGES + MANAGE_VIDEO_CHATS: Final[bool] = settings.MANAGE_VIDEO_CHATS + EDIT_MESSAGES: Final[bool] = settings.EDIT_MESSAGES + PIN_MESSAGE: Final[bool] = settings.PIN_MESSAGE + POST_STORIES: Final[bool] = settings.POST_STORIES + EDIT_STORIES: Final[bool] = settings.EDIT_STORIES + DELETE_STORIES: Final[bool] = settings.DELETE_STORIES + RIGHTS: Final[ChatAdministratorRights] = settings.rights + + +class RpValue: + """Переменные связанные с ролевым проектом.""" + RP_NAME: Final[str] = settings.RP_NAME + INFO_URL: str = settings.INFO_URL + FLUD_URL: str = settings.FLUD_URL + RP_URL: str = settings.RP_URL + LIFE_URL: str = settings.LIFE_URL + RP_OWNER: str = settings.RP_OWNER + ROLES: list[str] = settings.ROLES + + +class Project: + POSTS_DIR: ClassVar[Path] = Path('posts') + + +class Lists: + """Интересные списки фактов, цитат и анекдотов.""" + facts: list[str] = [ + "Python был создан Гвидо ван Россумом в 1991 году.", + "Имена Python и Monty Python связаны — язык назван в честь шоу.", + "Python — язык с динамической типизацией.", + "В Python всё является объектом, даже функции и типы данных.", + "Списки в Python — это изменяемые коллекции, в отличие от кортежей.", + "Python поддерживает парадигмы ООП, функционального и императивного программирования.", + "Zen of Python можно увидеть, набрав `import this` в интерпретаторе.", + ] + jokes: list[str] = [ + "1", + "2", + "3", + "4", + ] + quotes: list[str] = [ + "5", + "6", + "7", + "8", + ] + + + +# Экспорт совместимых компонентов +__all__ = ( + "BotSettings", + "LogConfig", + "Webhook", + "APISettings", + "UserIn", + "ImportantID", + "Permission", + "BotEdit", + "Project", + "RpValue", + 'settings', + 'Lists', +) \ No newline at end of file diff --git a/configs/roles.py b/configs/roles.py new file mode 100644 index 0000000..5e27e88 --- /dev/null +++ b/configs/roles.py @@ -0,0 +1,294 @@ +from database import RoleRegion + +# Настройка экспорта +__all__ = ("genshin_roles", "hsr_roles", "all_roles",) + +genshin_roles: list = [ + # Мондштадт + ("Альбедо", RoleRegion.MONDSTADT), + ("Барбара", RoleRegion.MONDSTADT), + ("Беннет", RoleRegion.MONDSTADT), + ("Венти", RoleRegion.MONDSTADT), + ("Далия", RoleRegion.MONDSTADT), + ("Джинн", RoleRegion.MONDSTADT), + ("Дилюк", RoleRegion.MONDSTADT), + ("Диона", RoleRegion.MONDSTADT), + ("Кли", RoleRegion.MONDSTADT), + ("Кэйа", RoleRegion.MONDSTADT), + ("Лиза", RoleRegion.MONDSTADT), + ("Мика", RoleRegion.MONDSTADT), + ("Мона", RoleRegion.MONDSTADT), + ("Ноэлль", RoleRegion.MONDSTADT), + ("Розария", RoleRegion.MONDSTADT), + ("Рэйзор", RoleRegion.MONDSTADT), + ("Сахароза", RoleRegion.MONDSTADT), + ("Фишль", RoleRegion.MONDSTADT), + ("Эмбер", RoleRegion.MONDSTADT), + ("Эола", RoleRegion.MONDSTADT), + + # Ли Юэ + ("Бай Чжу", RoleRegion.LIYUE), + ("Бэй Доу", RoleRegion.LIYUE), + ("Гань Юй", RoleRegion.LIYUE), + ("Е Лань", RoleRegion.LIYUE), + ("Ка Мин", RoleRegion.LIYUE), + ("Кэ Цин", RoleRegion.LIYUE), + ("Лань Янь", RoleRegion.LIYUE), + ("Нин Гуан", RoleRegion.LIYUE), + ("Син Цю", RoleRegion.LIYUE), + ("Синь Янь", RoleRegion.LIYUE), + ("Сян Лин", RoleRegion.LIYUE), + ("Сянь Юнь", RoleRegion.LIYUE), + ("Сяо", RoleRegion.LIYUE), + ("Ху Тао", RoleRegion.LIYUE), + ("Ци Ци", RoleRegion.LIYUE), + ("Чжун Ли", RoleRegion.LIYUE), + ("Чун Юнь", RoleRegion.LIYUE), + ("Шэнь Хэ", RoleRegion.LIYUE), + ("Юнь Цзинь", RoleRegion.LIYUE), + ("Янь Фэй", RoleRegion.LIYUE), + ("Яо Яо", RoleRegion.LIYUE), + + # Инадзума + ("Аяка", RoleRegion.INAZUMA), + ("Аято", RoleRegion.INAZUMA), + ("Горо", RoleRegion.INAZUMA), + ("Ёимия", RoleRegion.INAZUMA), + ("Итто", RoleRegion.INAZUMA), + ("Кадзуха", RoleRegion.INAZUMA), + ("Кирара", RoleRegion.INAZUMA), + ("Кокоми", RoleRegion.INAZUMA), + ("Мидзуки", RoleRegion.INAZUMA), + ("Райдэн Макото", RoleRegion.INAZUMA), + ("Райдэн Эи", RoleRegion.INAZUMA), + ("Сара", RoleRegion.INAZUMA), + ("Саю", RoleRegion.INAZUMA), + ("Синобу", RoleRegion.INAZUMA), + ("Тиори", RoleRegion.INAZUMA), + ("Тома", RoleRegion.INAZUMA), + ("Хэйдзо", RoleRegion.INAZUMA), + ("Яэ Мико", RoleRegion.INAZUMA), + + # Сумеру + ("Аль-Хайтам", RoleRegion.SUMERU), + ("Дори", RoleRegion.SUMERU), + ("Дэхья", RoleRegion.SUMERU), + ("Кавех", RoleRegion.SUMERU), + ("Кандакия", RoleRegion.SUMERU), + ("Коллеи", RoleRegion.SUMERU), + ("Лайла", RoleRegion.SUMERU), + ("Нахида", RoleRegion.SUMERU), + ("Нилу", RoleRegion.SUMERU), + ("Руккхадевата", RoleRegion.SUMERU), + ("Сайно", RoleRegion.SUMERU), + ("Сетос", RoleRegion.SUMERU), + ("Странник", RoleRegion.SUMERU), + ("Тигнари", RoleRegion.SUMERU), + ("Фарузан", RoleRegion.SUMERU), + + # Фонтейн + ("Клоринда", RoleRegion.FONTAINE), + ("Линетт", RoleRegion.FONTAINE), + ("Лини", RoleRegion.FONTAINE), + ("Навия", RoleRegion.FONTAINE), + ("Нёвиллет", RoleRegion.FONTAINE), + ("Ризли", RoleRegion.FONTAINE), + ("Сиджвин", RoleRegion.FONTAINE), + ("Фокалорс", RoleRegion.FONTAINE), + ("Фремине", RoleRegion.FONTAINE), + ("Фурина", RoleRegion.FONTAINE), + ("Шарлотта", RoleRegion.FONTAINE), + ("Шеврёз", RoleRegion.FONTAINE), + ("Эмилия", RoleRegion.FONTAINE), + ("Эскофье", RoleRegion.FONTAINE), + + # Натлан + ("Ахав", RoleRegion.NATLAN), + ("Вареса", RoleRegion.NATLAN), + ("Иансан", RoleRegion.NATLAN), + ("Ифа", RoleRegion.NATLAN), + ("Качина", RoleRegion.NATLAN), + ("Кинич", RoleRegion.NATLAN), + ("Мавуика", RoleRegion.NATLAN), + ("Муалани", RoleRegion.NATLAN), + ("Оророн", RoleRegion.NATLAN), + ("Ситлали", RoleRegion.NATLAN), + ("Часка", RoleRegion.NATLAN), + ("Шилонен", RoleRegion.NATLAN), + + # Снежная + ("Арлекино", RoleRegion.SNEZHNAYA), + ("Дотторе", RoleRegion.SNEZHNAYA), + ("Капитано", RoleRegion.SNEZHNAYA), + ("Коломбина", RoleRegion.SNEZHNAYA), + ("Панталоне", RoleRegion.SNEZHNAYA), + ("Пульчинелла", RoleRegion.SNEZHNAYA), + ("Пьеро", RoleRegion.SNEZHNAYA), + ("Сандроне", RoleRegion.SNEZHNAYA), + ("Синьора", RoleRegion.SNEZHNAYA), + ("Царица", RoleRegion.SNEZHNAYA), + ("Тарталья", RoleRegion.SNEZHNAYA), + + # Каэнри'ах + ("Айно", RoleRegion.KHAENRIAH), + ("Алиса", RoleRegion.KHAENRIAH), + ("Варка", RoleRegion.KHAENRIAH), + ("Дурин", RoleRegion.KHAENRIAH), + ("Инеффа", RoleRegion.KHAENRIAH), + ("Лаума", RoleRegion.KHAENRIAH), + ("Нефер", RoleRegion.KHAENRIAH), + ("Николь", RoleRegion.KHAENRIAH), + ("Флинс", RoleRegion.KHAENRIAH), + ("Ягода", RoleRegion.KHAENRIAH), + + # Другие (Genshin Impact) + ("Дайнслейф", RoleRegion.GENSHIN_OTHER), + ("Итэр", RoleRegion.GENSHIN_OTHER), + ("Люмин", RoleRegion.GENSHIN_OTHER), + ("Паймон", RoleRegion.GENSHIN_OTHER), + ("Рэйндоттир", RoleRegion.GENSHIN_OTHER), + ("Скирк", RoleRegion.GENSHIN_OTHER), + ("Элой", RoleRegion.GENSHIN_OTHER), + ] + +# Роли для Honkai: Star Rail +hsr_roles: list = [ + # Звездный экспресс + ("Вельт", RoleRegion.HSR_STAR), + ("Дань Хэн", RoleRegion.HSR_STAR), + ("Келус", RoleRegion.HSR_STAR), + ("Март 7", RoleRegion.HSR_STAR), + ("Стелла", RoleRegion.HSR_STAR), + ("Химеко", RoleRegion.HSR_STAR), + + # Космическая станция Герта + ("Арлан", RoleRegion.HSR_GERTA), + ("Аста", RoleRegion.HSR_GERTA), + ("Великая Герта", RoleRegion.HSR_GERTA), + ("Жуань Мэй", RoleRegion.HSR_GERTA), + ("Полька Какамонд", RoleRegion.HSR_GERTA), + ("Скрюллум", RoleRegion.HSR_GERTA), + ("Стивен Ллойд", RoleRegion.HSR_GERTA), + + # Ярило-VI + ("Броня", RoleRegion.HSR_YARILO), + ("Гепард", RoleRegion.HSR_YARILO), + ("Зеле", RoleRegion.HSR_YARILO), + ("Клара", RoleRegion.HSR_YARILO), + ("Коколия", RoleRegion.HSR_YARILO), + ("Лука", RoleRegion.HSR_YARILO), + ("Наташа", RoleRegion.HSR_YARILO), + ("Пела", RoleRegion.HSR_YARILO), + ("Рысь", RoleRegion.HSR_YARILO), + ("Сампо", RoleRegion.HSR_YARILO), + ("Сервал", RoleRegion.HSR_YARILO), + ("Хук", RoleRegion.HSR_YARILO), + + # Лофу Сяньчжоу + ("Байлу", RoleRegion.HSR_LOFU), + ("Байхэн", RoleRegion.HSR_LOFU), + ("Гуйнайфэнь", RoleRegion.HSR_LOFU), + ("Линша", RoleRegion.HSR_LOFU), + ("Лоча", RoleRegion.HSR_LOFU), + ("Моцзэ", RoleRegion.HSR_LOFU), + ("Сушан", RoleRegion.HSR_LOFU), + ("Сюэи", RoleRegion.HSR_LOFU), + ("Фу Сюань", RoleRegion.HSR_LOFU), + ("Фуга", RoleRegion.HSR_LOFU), + ("Фэйсяо", RoleRegion.HSR_LOFU), + ("Ханья", RoleRegion.HSR_LOFU), + ("Хохо", RoleRegion.HSR_LOFU), + ("Цзинлю", RoleRegion.HSR_LOFU), + ("Цзин Юань", RoleRegion.HSR_LOFU), + ("Цзяоцю", RoleRegion.HSR_LOFU), + ("Цинцюэ", RoleRegion.HSR_LOFU), + ("Юйкун", RoleRegion.HSR_LOFU), + ("Юньли", RoleRegion.HSR_LOFU), + ("Яньцин", RoleRegion.HSR_LOFU), + + # Пенакония + ("Ахерон", RoleRegion.HSR_PENACONY), + ("Воскресенье", RoleRegion.HSR_PENACONY), + ("Галлахер", RoleRegion.HSR_PENACONY), + ("Мистер Река", RoleRegion.HSR_PENACONY), + ("Зарянка", RoleRegion.HSR_PENACONY), + ("Искорка", RoleRegion.HSR_PENACONY), + ("Миша", RoleRegion.HSR_PENACONY), + ("Рацио", RoleRegion.HSR_PENACONY), + ("Чёрный Лебедь", RoleRegion.HSR_PENACONY), + + # Амфореус + ("Аглая", RoleRegion.HSR_AMPHOREUS), + ("Анаксагор", RoleRegion.HSR_AMPHOREUS), + ("Гиацина", RoleRegion.HSR_AMPHOREUS), + ("Гисиленса", RoleRegion.HSR_AMPHOREUS), + ("Кастория", RoleRegion.HSR_AMPHOREUS), + ("Керидра", RoleRegion.HSR_AMPHOREUS), + ("Кирена", RoleRegion.HSR_AMPHOREUS), + ("Ликург", RoleRegion.HSR_AMPHOREUS), + ("Мидей", RoleRegion.HSR_AMPHOREUS), + ("Трибби", RoleRegion.HSR_AMPHOREUS), + ("Фаенон", RoleRegion.HSR_AMPHOREUS), + ("Цифер", RoleRegion.HSR_AMPHOREUS), + + # Охотники за Стеллар + ("Блэйд", RoleRegion.HSR_HUNTER), + ("Кафка", RoleRegion.HSR_HUNTER), + ("Светлячок", RoleRegion.HSR_HUNTER), + ("Серебряный Волк", RoleRegion.HSR_HUNTER), + ("Элио", RoleRegion.HSR_HUNTER), + + # КММ + ("Авантюрин", RoleRegion.HSR_KMM), + ("Агат", RoleRegion.HSR_KMM), + ("Алмаз", RoleRegion.HSR_KMM), + ("Обсидиан", RoleRegion.HSR_KMM), + ("Опал", RoleRegion.HSR_KMM), + ("Перламутр", RoleRegion.HSR_KMM), + ("Сапфир", RoleRegion.HSR_KMM), + ("Сугилит", RoleRegion.HSR_KMM), + ("Топаз", RoleRegion.HSR_KMM), + ("Янтарь", RoleRegion.HSR_KMM), + ("Яшма", RoleRegion.HSR_KMM), + + # Эоны + ("Акивили", RoleRegion.HSR_EONS), + ("Аха", RoleRegion.HSR_EONS), + ("Клипот", RoleRegion.HSR_EONS), + ("Лань", RoleRegion.HSR_EONS), + ("Нанук", RoleRegion.HSR_EONS), + ("Нус", RoleRegion.HSR_EONS), + ("Ороборос", RoleRegion.HSR_EONS), + ("Тайззиронт", RoleRegion.HSR_EONS), + ("Фили", RoleRegion.HSR_EONS), + ("Шипе", RoleRegion.HSR_EONS), + ("Эна", RoleRegion.HSR_EONS), + ("Яоши", RoleRegion.HSR_EONS), + ("IX", RoleRegion.HSR_EONS), + + # Вечногорящий особняк + ("Акаш", RoleRegion.HSR_FIRE_MANSION), + ("Герцог Инферно", RoleRegion.HSR_FIRE_MANSION), + ("Дубра", RoleRegion.HSR_FIRE_MANSION), + ("Катерина", RoleRegion.HSR_FIRE_MANSION), + ("Констанция", RoleRegion.HSR_FIRE_MANSION), + + # Лорды Опустошители + ("Асат Прамад", RoleRegion.HSR_LORDS), + ("Зефиро", RoleRegion.HSR_LORDS), + ("Оростелла", RoleRegion.HSR_LORDS), + ("Фантилия", RoleRegion.HSR_LORDS), + + # Прочие (Honkai: Star Rail) + ("Аргенти", RoleRegion.HSR_OTHER), + ("Бутхилл", RoleRegion.HSR_OTHER), + ("Раппа", RoleRegion.HSR_OTHER), + ("Архив Пустоты", RoleRegion.HSR_OTHER), + + # Фейт + ("Арчер", RoleRegion.HSR_FATE), + ("Сейбер", RoleRegion.HSR_FATE), + ] + +# Общий список ролей +all_roles: list = genshin_roles + hsr_roles diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..318dc67 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .database import * \ No newline at end of file diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..b9f5bb4 --- /dev/null +++ b/database/database.py @@ -0,0 +1,1171 @@ +from __future__ import annotations + +import enum +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple, List, Protocol, runtime_checkable, Dict, Any, Union + +from sqlalchemy import ( + BigInteger, + String, + DateTime, + ForeignKey, + Text, + Enum as SAEnum, + func, + select, + and_, + Integer, case, +) +from sqlalchemy import text as sql_text +from sqlalchemy.engine import Result +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, +) +from sqlalchemy.sql import Select + +__all__: Tuple[str, ...] = ( + "UserStatus", + "User", + "UserMessage", + "Role", + "RoleRegion", + "RoleMessage", + "BotDatabase", + "db" +) + + +# ====================================================== +# База декларативных моделей (SQLAlchemy 2.0 style) +# ====================================================== +class Base(DeclarativeBase): + """Базовый класс декларативных моделей SQLAlchemy.""" + pass + + +# ====================================================== +# Перечисления / константы +# ====================================================== +class UserStatus(str, enum.Enum): + """ + Статус пользователя в системе. + + Значения: + - ACTIVE — обычный пользователь + - ADMIN — администратор + - BANNED — заблокирован + """ + ACTIVE = "active" + ADMIN = "admin" + BANNED = "banned" + + +class RoleRegion(str, enum.Enum): + """ + Регионы персонажей в играх. + + Значения для Genshin Impact: + - MONDSTADT - Мондштадт + - LIYUE - Ли Юэ + - INAZUMA - Инадзума + - SUMERU - Сумеру + - FONTAINE - Фонтейн + - NATLAN - Натлан + - SNEZHNAYA - Снежная + - KHAENRIAH - Каэнри'ах + - GENSHIN_OTHER - Другие (Genshin Impact) + + Значения для Honkai: Star Rail: + - HSR_STAR - Звездный экспресс + - HSR_GERTA - Космическая станция Герта + - HSR_YARILO - Ярило-VI + - HSR_LOFU - Лофу Сяньчжоу + - HSR_PENACONY - Пенакония + - HSR_AMPHOREUS - Амфореус + - HSR_HUNTER - Охотники за Стеллар + - HSR_KMM - КММ + - HSR_EONS - Эоны + - HSR_FIRE_MANSION - Вечногорящий особняк + - HSR_LORDS - Лорды Опустошители + - HSR_OTHER - Прочие (Honkai: Star Rail) + - HSR_FATE - Фейт + """ + # Genshin Impact регионы + MONDSTADT = "Мондштадт" + LIYUE = "Ли Юэ" + INAZUMA = "Инадзума" + SUMERU = "Сумеру" + FONTAINE = "Фонтейн" + NATLAN = "Натлан" + SNEZHNAYA = "Снежная" + KHAENRIAH = "Каэнри'ах" + GENSHIN_OTHER = "Другие (Genshin Impact)" + + # Honkai: Star Rail регионы + HSR_STAR = "Звездный экспресс" + HSR_GERTA = "Космическая станция Герта" + HSR_YARILO = "Ярило-VI" + HSR_LOFU = "Лофу Сяньчжоу" + HSR_PENACONY = "Пенакония" + HSR_AMPHOREUS = "Амфореус" + HSR_HUNTER = "Охотники за Стеллар" + HSR_KMM = "КММ" + HSR_EONS = "Эоны" + HSR_FIRE_MANSION = "Вечногорящий особняк" + HSR_LORDS = "Лорды Опустошители" + HSR_OTHER = "Прочие (Honkai: Star Rail)" + HSR_FATE = "Фейт" + + +# ====================================================== +# Протоколы для минимальной типизации aiogram-сообщений +# (чтобы не тянуть aiogram как зависимость, но иметь строгие типы) +# ====================================================== +@runtime_checkable +class SupportsUser(Protocol): + """Протокол для объекта пользователя с обязательными полями.""" + id: int + username: Optional[str] + full_name: Optional[str] + + +@runtime_checkable +class SupportsAiogramMessage(Protocol): + """Протокол для объекта сообщения с обязательными полями.""" + from_user: SupportsUser + text: Optional[str] + + +@runtime_checkable +class SupportsAiogramBot(Protocol): + """Протокол для объекта бота с методом редактирования сообщений.""" + + async def edit_message_text( + self, + chat_id: Union[int, str], + message_id: int, + text: str, + **kwargs: Any + ) -> Any: + ... + + +# ====================================================== +# Модели +# ====================================================== +class User(Base): + """ + Модель пользователя Telegram. + + Таблица: + users + + Атрибуты: + id (int) - Telegram ID пользователя (PK) + username (Optional[str]) - Никнейм (@username) + full_name (Optional[str]) - Полное имя + status (UserStatus) - Статус: active/admin/banned + created_at (datetime) - Дата создания записи (tz-aware) + updated_at (datetime) - Дата последнего обновления (tz-aware) + messages (List[UserMessage]) - Связанные сообщения + roles (List[Role]) - Роли, которые занимает пользователь + + Индексы: + - ix_users_status + - ix_users_username + + Пример: + >> user = User(id=123, username="test", full_name="Test User") + """ + + __tablename__: str = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[Optional[str]] = mapped_column(String, nullable=True) + full_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + + # Для SQLite используем строковый Enum (native_enum=False) + status: Mapped[UserStatus] = mapped_column( + SAEnum(UserStatus, native_enum=False), + nullable=False, + default=UserStatus.ACTIVE, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + messages: Mapped[List["UserMessage"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + roles: Mapped[List["Role"]] = relationship( + back_populates="occupied_by_user", + passive_deletes=True, + ) + + +class UserMessage(Base): + """ + Сообщение пользователя. + + Таблица: + user_messages + + Атрибуты: + id (int) - ID сообщения (PK) + user_id (int) - FK -> users.id + message_text (str) - Текст сообщения + created_at (datetime) - Метка времени (UTC, tz-aware) + + Индексы: + - ix_user_messages_user_id_created_at (user_id, created_at) + + Пример: + >> message = UserMessage(user_id=123, message_text="Hello") + """ + + __tablename__: str = "user_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + + user: Mapped["User"] = relationship(back_populates="messages") + + +class Role(Base): + """ + Роль (персонаж). + + Таблица: + roles + + Атрибуты: + id (int) - ID роли (PK) + name (str) - Название роли (уникально) + region (RoleRegion) - Регион персонажа + occupied_by (Optional[int]) - Пользователь, который занимает роль (FK -> users.id) + occupied_by_user (Optional[User]) - Обратная связь на пользователя + + Ограничения: + - Уникальность name + + Пример: + >> role = Role(name="Альбедо", region=RoleRegion.MONDSTADT) + """ + + __tablename__: str = "roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False, unique=True) + region: Mapped[RoleRegion] = mapped_column( + SAEnum(RoleRegion, native_enum=False), + nullable=False, + default=RoleRegion.GENSHIN_OTHER, + ) + occupied_by: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id"), + nullable=True + ) + occupied_by_user: Mapped[Optional["User"]] = relationship( + "User", + back_populates="roles" + ) + + +class RoleMessage(Base): + """ + Модель для хранения информации о сообщениях с списками ролей. + + Таблица: + role_messages + + Атрибуты: + id (int) - ID записи + game_type (str) - тип игры ('genshin' или 'hsr') + channel_id (int) - ID канала + message_id (int) - ID сообщения + message_text (str) - исходный текст сообщения + + Пример: + >> role_msg = RoleMessage( + >> game_type="genshin", + >> channel_id=-100123456, + >> message_id=123, + >> message_text="Список персонажей" + >> ) + """ + __tablename__: str = "role_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + game_type: Mapped[str] = mapped_column(String, nullable=False) # 'genshin' или 'hsr' + channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + message_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + + +# ====================================================== +# Утилиты для расчёта периодов (день/неделя/месяц) +# ====================================================== +def _start_of_day(dt: datetime) -> datetime: + """ + Начало дня для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: 00:00:00 того же дня. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_day(now) + """ + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_week_monday(dt: datetime) -> datetime: + """ + Начало недели (понедельник 00:00:00) для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: понедельник 00:00:00 текущей недели. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_week_monday(now) + """ + monday: datetime = dt - timedelta(days=dt.weekday()) + return monday.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_month(dt: datetime) -> datetime: + """ + Начало месяца (первое число 00:00:00) для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: первое число 00:00:00 текущего месяца. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_month(now) + """ + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +# ====================================================== +# Класс управления базой данных +# ====================================================== +class BotDatabase: + """ + Асинхронный менеджер базы данных для Telegram-бота (SQLite + SQLAlchemy 2.x). + + Возможности: + - Автосоздание базы и всех таблиц. + - Учёт пользователей (регистрация, бан, разбан, список ID). + - Учёт сообщений (логирование, статистика за день/неделю/месяц/всё время). + - Система ролей (создание, назначение, освобождение, статус). + - Управление сообщениями с ролями в Telegram. + + Замечания: + - Все временные метки сохраняются в UTC (tz-aware). + - Неделя считается с понедельника по воскресенье. + + Пример: + >> db = BotDatabase() # doctest: +SKIP + >> await db.init_db() # doctest: +SKIP + >> await db.add_user(1001, "user1", "User One") # doctest: +SKIP + >> await db.add_message(1001, "Привет") # doctest: +SKIP + >> await db.init_roles([ # doctest: +SKIP + >> ("Альбедо", RoleRegion.MONDSTADT), # doctest: +SKIP + >> ("Чжун Ли", RoleRegion.LIYUE) # doctest: +SKIP + >> ]) # doctest: +SKIP + >> await db.assign_role("Альбедо", 1001) # doctest: +SKIP + >> ids = await db.get_user_ids() # doctest: +SKIP + >> stats = await db.get_message_stats(1001) # doctest: +SKIP + """ + + DEFAULT_DB_URL: str = "sqlite+aiosqlite:///./bot.db" + + def __init__(self, db_url: Optional[str] = None, echo: bool = False) -> None: + """ + Инициализация менеджера БД. + + Args: + db_url: строка подключения к базе (по умолчанию создаётся ./bot.db). + echo: логирование SQL (для отладки). + + Raises: + ValueError: если db_url пустая строка. + + Пример: + >> db = BotDatabase() # Создаст базу в файле ./bot.db + >> db = BotDatabase("sqlite+aiosqlite:///./test.db", echo=True) # Создаст test.db с логированием SQL + """ + if db_url is not None and not db_url.strip(): + raise ValueError("db_url не может быть пустой строкой") + + url: str = db_url or self.DEFAULT_DB_URL + self.engine: AsyncEngine = create_async_engine(url, echo=echo, future=True) + self.session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=self.engine, expire_on_commit=False, class_=AsyncSession + ) + + # ----------------------- Инициализация схемы ----------------------- + async def init_db(self) -> None: + """ + Создаёт все таблицы в базе данных. + + Пример: + >> await db.init_db() + """ + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def dispose(self) -> None: + """ + Корректно закрывает соединения с БД. + + Пример: + >> await db.dispose() + """ + await self.engine.dispose() + + # ----------------------- Пользователи ----------------------- + async def add_user( + self, + user_id: int, + username: Optional[str] = None, + full_name: Optional[str] = None, + is_admin: bool = False, + ) -> None: + """ + Регистрирует пользователя (idempotent): если запись уже есть — ничего не делает. + + Args: + user_id: Telegram ID пользователя. + username: никнейм (@username). + full_name: полное имя. + is_admin: установить статус ADMIN при создании. + + Пример: + >> await db.add_user(42, username="neo", full_name="Thomas Anderson", is_admin=True) + """ + async with self.session_factory() as session: + existing: Optional[User] = await session.get(User, user_id) + if existing is not None: + return + + status: UserStatus = UserStatus.ADMIN if is_admin else UserStatus.ACTIVE + new_user: User = User( + id=user_id, + username=username, + full_name=full_name, + status=status, + ) + session.add(new_user) + await session.commit() + + async def ensure_user_from_message(self, message: SupportsAiogramMessage) -> None: + """ + Гарантирует наличие пользователя в БД на основе aiogram-сообщения. + + Args: + message: объект, совместимый с aiogram.types.Message (имеет from_user с полями id/username/full_name). + + Пример: + >> # в хендлере aiogram: + >> await db.ensure_user_from_message(message) + """ + # Строгая проверка протокола: + if not isinstance(message, SupportsAiogramMessage): + raise TypeError("message не соответствует протоколу SupportsAiogramMessage") + + from_user: SupportsUser = message.from_user + if not isinstance(from_user, SupportsUser): + raise TypeError("message.from_user не соответствует протоколу SupportsUser") + + await self.add_user( + user_id=from_user.id, + username=from_user.username, + full_name=from_user.full_name, + ) + + async def set_admin(self, user_id: int, make_admin: bool = True) -> None: + """ + Повышает/понижает пользователя до/с администратора. + + Args: + user_id: Telegram ID. + make_admin: True — сделать админом, False — вернуть в ACTIVE. + + Пример: + >> await db.set_admin(42, make_admin=True) # Сделать админом + >> await db.set_admin(42, make_admin=False) # Убрать админа + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + user.status = UserStatus.ADMIN if make_admin else UserStatus.ACTIVE + await session.commit() # Убедитесь, что этот вызов есть! + + async def ban_user(self, user_id: int) -> None: + """ + Банит пользователя (status=BANNED). + + Args: + user_id: Telegram ID. + + Пример: + >> await db.ban_user(1001) + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + user.status = UserStatus.BANNED + await session.commit() + + async def unban_user(self, user_id: int) -> None: + """ + Разбанивает пользователя (возвращает в ACTIVE, если был BANNED). + + Args: + user_id: Telegram ID. + + Пример: + >> await db.unban_user(1001) + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + if user.status == UserStatus.BANNED: + user.status = UserStatus.ACTIVE + await session.commit() + + async def get_user(self, user_id: int) -> Optional[User]: + """ + Возвращает пользователя по ID. + + Args: + user_id: Telegram ID. + + Returns: + Optional[User]: объект или None. + + Пример: + >> user = await db.get_user(1001) + >> if user: + >> print(user.username) + """ + async with self.session_factory() as session: + return await session.get(User, user_id) + + async def get_all_users(self, include_banned: bool = False) -> List[User]: + """ + Возвращает список пользователей. + + Args: + include_banned: включать ли забаненных (по умолчанию False). + + Returns: + List[User]: список пользователей. + + Пример: + >> users = await db.get_all_users() + >> for user in users: + >> print(f"{user.id}: {user.username}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[User]] = select(User) + if not include_banned: + stmt = stmt.where(User.status != UserStatus.BANNED) + res: Result[Tuple[User]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_user_ids( + self, + only_active: bool = True, + include_admins: bool = True, + order_asc: bool = True, + ) -> List[int]: + """ + Возвращает список ID пользователей (для рассылок и т.п.). + + Args: + only_active: Исключать ли забаненных (True по умолчанию). + include_admins: Включать ли администраторов. + order_asc: Сортировать по возрастанию (иначе по убыванию). + + Returns: + List[int]: список Telegram ID. + + Пример: + >> active_user_ids = await db.get_user_ids(only_active=True, include_admins=False) + """ + async with self.session_factory() as session: + stmt: Select[Tuple[int]] = select(User.id) + if only_active: + stmt = stmt.where(User.status != UserStatus.BANNED) + if not include_admins: + stmt = stmt.where(User.status != UserStatus.ADMIN) + stmt = stmt.order_by(User.id.asc() if order_asc else User.id.desc()) + + res: Result[Tuple[int]] = await session.execute(stmt) + ids: List[int] = list(res.scalars().all()) + return ids + + # ----------------------- Сообщения / статистика ----------------------- + async def add_message( + self, + user_id: int, + message_text: str, + created_at: Optional[datetime] = None, + ) -> None: + async with self.session_factory() as session: + # Сначала пытаемся найти пользователя + user: Optional[User] = await session.get(User, user_id) + + # Если пользователя нет, создаем его + if user is None: + user = User(id=user_id, status=UserStatus.ACTIVE) + session.add(user) + await session.flush() + + # Используем переданную дату или текущее время + if created_at is not None: + # Убедимся, что дата имеет временную зону + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + ts = created_at + else: + ts = datetime.now(timezone.utc) + + record: UserMessage = UserMessage(user_id=user_id, message_text=message_text, created_at=ts) + session.add(record) + await session.commit() + + async def check_connection(self) -> bool: + """Проверяет соединение с базой данных""" + try: + async with self.session_factory() as session: + await session.execute(sql_text("SELECT 1")) + return True + except Exception as e: + print(f"Ошибка подключения к БД: {e}") + return False + + async def add_message_from_message(self, message: SupportsAiogramMessage) -> None: + """ + Логирует сообщение напрямую из aiogram (минимальный контракт через Protocol). + + Args: + message: объект, совместимый с aiogram.types.Message. + + Пример: + >> await db.add_message_from_message(message) + """ + if not isinstance(message, SupportsAiogramMessage): + raise TypeError("message не соответствует протоколу SupportsAiogramMessage") + from_user: SupportsUser = message.from_user + if not isinstance(from_user, SupportsUser): + raise TypeError("message.from_user не соответствует протоколу SupportsUser") + + await self.add_message( + user_id=from_user.id, + message_text=message.text or "", + ) + + async def get_message_stats(self, user_id: int) -> Tuple[int, int, int, int]: + """ + Возвращает статистику сообщений пользователя: + (за текущий день, за текущую неделю [Пн-Вс], за текущий месяц, за всё время). + + Все границы считаются по UTC и округляются к началу периода. + + Args: + user_id: Telegram ID. + + Returns: + Tuple[int, int, int, int]: (day, week, month, total) + + Пример: + >> day, week, month, total = await db.get_message_stats(1001) + >> print(f"За день: {day}, за неделю: {week}, за месяц: {month}, всего: {total}") + """ + async with self.session_factory() as session: + now: datetime = datetime.now(timezone.utc) + day_start: datetime = _start_of_day(now) + week_start: datetime = _start_of_week_monday(now) + month_start: datetime = _start_of_month(now) + epoch_start: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc) + + async def _count_from(since: datetime) -> int: + stmt: Select[Tuple[int]] = select(func.count()).where( + and_(UserMessage.user_id == user_id, UserMessage.created_at >= since) + ) + res: Result[Tuple[int]] = await session.execute(stmt) + return int(res.scalar() or 0) + + day_count: int = await _count_from(day_start) + week_count: int = await _count_from(week_start) + month_count: int = await _count_from(month_start) + total_count: int = await _count_from(epoch_start) + + return day_count, week_count, month_count, total_count + + # ----------------------- Роли ----------------------- + async def init_roles(self, roles: List[Tuple[str, RoleRegion]]) -> None: + """ + Создаёт роли персонажей. Если таблицы нет — создаёт таблицы. + + Args: + roles: список кортежей (имя_роли, регион) + + Пример: + >> roles = [ + >> ("Альбедо", RoleRegion.MONDSTADT), + >> ("Чжун Ли", RoleRegion.LIYUE), + >> ("Вельт", RoleRegion.HSR_STAR) + >> ] + >> await db.init_roles(roles) + """ + # создаём таблицы перед вставкой ролей + await self.init_db() + + async with self.session_factory() as session: + for name, region in roles: + stmt = select(Role).where(Role.name == name) + res = await session.execute(stmt) + role = res.scalar_one_or_none() + if not role: + session.add(Role(name=name, region=region)) + await session.commit() + + async def assign_role(self, role_name: str, user_id: int, bot: Optional[SupportsAiogramBot] = None) -> bool: + """ + Назначает пользователя на роль, если она свободна. + + Args: + role_name: название роли (уникальное). + user_id: Telegram ID пользователя. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + bool: True — если назначение выполнено, False — если такой роли нет или роль уже занята. + + Пример: + >> success = await db.assign_role("Альбедо", 1001, bot) + >> if success: + >> print("Роль назначена") + """ + async with self.session_factory() as session: + role_stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + role_res: Result[Tuple[Role]] = await session.execute(role_stmt) + role: Optional[Role] = role_res.scalar_one_or_none() + if role is None or role.occupied_by is not None: + return False + + user: Optional[User] = await session.get(User, user_id) + if user is None or user.status == UserStatus.BANNED: + return False + + role.occupied_by = user_id + await session.commit() + + # Обновляем сообщение с ролями если передан бот + if bot: + # Определяем игру по региону + game_type: str = "hsr" if role.region.name.startswith("HSR_") else "genshin" + await self.update_role_message(game_type, bot) + + return True + + async def release_role(self, role_name: str, bot: Optional[SupportsAiogramBot] = None) -> bool: + """ + Освобождает роль (устанавливает occupied_by = NULL). + + Args: + role_name: название роли. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + bool: True если роль была освобождена, False если роль не найдена или уже свободна. + + Пример: + >> success = await db.release_role("Альбедо", bot) + >> if success: + >> print("Роль освобождена") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + res: Result[Tuple[Role]] = await session.execute(stmt) + role: Optional[Role] = res.scalar_one_or_none() + + if role is None or role.occupied_by is None: + return False + + role.occupied_by = None + await session.commit() + + # Обновляем сообщение с ролями если передан бот + if bot: + # Определяем игру по региону + game_type: str = "hsr" if role.region.name.startswith("HSR_") else "genshin" + await self.update_role_message(game_type, bot) + + return True + + async def get_role_status(self) -> List[Tuple[str, Optional[int]]]: + """ + Возвращает текущий статус всех ролей. + + Returns: + List[Tuple[str, Optional[int]]]: пары (role_name, user_id | None) + + Пример: + >> roles = await db.get_role_status() + >> for name, user_id in roles: + >> status = "занята" if user_id else "свободна" + >> print(f"{name}: {status}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).order_by(Role.name.asc()) + res: Result[Tuple[Role]] = await session.execute(stmt) + roles: List[Role] = list(res.scalars().all()) + return [(r.name, r.occupied_by) for r in roles] + + async def get_roles_by_user(self, user_id: int) -> List[str]: + """ + Возвращает список имён ролей, занятых указанным пользователем. + + Args: + user_id: Telegram ID. + + Returns: + List[str]: имена ролей. + + Пример: + >> roles = await db.get_roles_by_user(1001) + >> print(f"Пользователь занимает роли: {', '.join(roles)}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[str]] = select(Role.name).where(Role.occupied_by == user_id) + res: Result[Tuple[str]] = await session.execute(stmt) + names: List[str] = list(res.scalars().all()) + return names + + async def release_roles_by_user(self, user_id: int, bot: Optional[SupportsAiogramBot] = None) -> int: + """ + Освобождает все роли, занятые указанным пользователем. + + Args: + user_id: Telegram ID. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + int: количество освобождённых ролей. + + Пример: + >> count = await db.release_roles_by_user(1001, bot) + >> print(f"Освобождено {count} ролей") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by == user_id) + res: Result[Tuple[Role]] = await session.execute(stmt) + roles: List[Role] = list(res.scalars().all()) + + for r in roles: + r.occupied_by = None + + if roles: + await session.commit() + + # Обновляем сообщения с ролями если передан бот + if bot: + # Обновляем оба типа сообщений, так как пользователь мог занимать роли в обеих играх + await self.update_role_message("genshin", bot) + await self.update_role_message("hsr", bot) + + return len(roles) + + async def get_available_roles(self, region: Optional[RoleRegion] = None) -> List[Role]: + """ + Возвращает список свободных ролей. + + Args: + region: фильтр по региону (опционально). + + Returns: + List[Role]: список свободных ролей. + + Пример: + >> free_roles = await db.get_available_roles(RoleRegion.MONDSTADT) + >> for role in free_roles: + >> print(role.name) + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by.is_(None)) + if region: + stmt = stmt.where(Role.region == region) + stmt = stmt.order_by(Role.name.asc()) + + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_occupied_roles(self, region: Optional[RoleRegion] = None) -> List[Role]: + """ + Возвращает список занятых ролей. + + Args: + region: фильтр по региону (опционально). + + Returns: + List[Role]: список занятых ролей. + + Пример: + >> occupied_roles = await db.get_occupied_roles(RoleRegion.MONDSTADT) + >> for role in occupied_roles: + >> print(f"{role.name} занята пользователем {role.occupied_by}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by.is_not(None)) + if region: + stmt = stmt.where(Role.region == region) + stmt = stmt.order_by(Role.name.asc()) + + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_role_by_name(self, role_name: str) -> Optional[Role]: + """ + Возвращает роль по имени. + + Args: + role_name: название роли. + + Returns: + Optional[Role]: объект роли или None если не найдена. + + Пример: + >> role = await db.get_role_by_name("Альбедо") + >> if role: + >> print(f"Регион: {role.region}, занята: {role.occupied_by is not None}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + res: Result[Tuple[Role]] = await session.execute(stmt) + return res.scalar_one_or_none() + + async def get_roles_by_region(self, region: RoleRegion) -> List[Role]: + """ + Возвращает все роли в указанном регионе. + + Args: + region: регион для фильтрации. + + Returns: + List[Role]: список ролей в регионе. + + Пример: + >> mondstadt_roles = await db.get_roles_by_region(RoleRegion.MONDSTADT) + >> for role in mondstadt_roles: + >> status = "занята" if role.occupied_by else "свободна" + >> print(f"{role.name}: {status}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.region == region).order_by(Role.name.asc()) + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_region_stats(self) -> Dict[RoleRegion, Dict[str, int]]: + """ + Возвращает статистику по регионам: количество свободных и занятых ролей. + """ + async with self.session_factory() as session: + # Используем агрегатные функции для подсчета статистики + stmt = select( + Role.region, + func.count().label("total"), + func.sum(case((Role.occupied_by.is_not(None), 1), else_=0)).label("occupied"), + func.sum(case((Role.occupied_by.is_(None), 1), else_=0)).label("free") + ).group_by(Role.region).order_by(Role.region.asc()) + + res = await session.execute(stmt) + results = res.all() + + stats: Dict[RoleRegion, Dict[str, int]] = {} + for region, total, occupied, free in results: + stats[region] = { + "total": total, + "occupied": occupied, + "free": free + } + + return stats + + # ----------------------- Управление сообщениями с ролями ----------------------- + async def save_role_message( + self, + game_type: str, + channel_id: int, + message_id: int, + message_text: str + ) -> None: + """ + Сохраняет информацию о сообщении со списком ролей. + + Args: + game_type: 'genshin' или 'hsr' + channel_id: ID канала + message_id: ID сообщения + message_text: исходный текст сообщения + + Пример: + >> await db.save_role_message( + >> game_type="genshin", + >> channel_id=-100123456, + >> message_id=123, + >> message_text="Список персонажей Genshin Impact" + >> ) + """ + async with self.session_factory() as session: + # Удаляем старую запись если есть + stmt = select(RoleMessage).where(RoleMessage.game_type == game_type) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + await session.delete(existing) + + # Создаем новую запись + new_message = RoleMessage( + game_type=game_type, + channel_id=channel_id, + message_id=message_id, + message_text=message_text + ) + session.add(new_message) + await session.commit() + + async def update_role_message(self, game_type: str, bot: SupportsAiogramBot) -> bool: + """ + Обновляет сообщение со списком ролей в Telegram. + + Args: + game_type: 'genshin' или 'hsr' + bot: экземпляр aiogram Bot + + Returns: + bool: True если сообщение обновлено, False если сообщение не найдено + + Пример: + >> success = await db.update_role_message("genshin", bot) + >> if success: + >> print("Сообщение обновлено") + """ + async with self.session_factory() as session: + # Получаем информацию о сообщении + stmt = select(RoleMessage).where(RoleMessage.game_type == game_type) + result = await session.execute(stmt) + role_message = result.scalar_one_or_none() + + if not role_message: + return False + + # Получаем статус всех ролей + roles_status = await self.get_role_status() + role_status_dict = {name: user_id for name, user_id in roles_status} + + # Обновляем текст сообщения + lines = role_message.message_text.split('\n') + updated_lines = [] + + for line in lines: + # Пропускаем заголовки и пустые строки + if not line.strip() or any(marker in line for marker in ['ᵎ', 'СПИСОК', 'Если персонажа']): + updated_lines.append(line) + continue + + # Проверяем, есть ли роль в этом сообщении + role_name = line.strip().replace('✅', '').replace('🕒', '').strip() + + if role_name in role_status_dict: + if role_status_dict[role_name] is not None: + # Роль занята - добавляем галочку + updated_line = f"{role_name} ✅" + else: + # Роль свободна - оставляем как есть + updated_line = role_name + else: + # Роль не найдена в базе - оставляем как есть + updated_line = line + + updated_lines.append(updated_line) + + updated_text = '\n'.join(updated_lines) + + # Обновляем сообщение в Telegram + try: + await bot.edit_message_text( + chat_id=role_message.channel_id, + message_id=role_message.message_id, + text=updated_text + ) + return True + except Exception as e: + print(f"Ошибка при обновлении сообщения: {e}") + return False + + async def init_default_roles(self) -> None: + """ + Инициализирует стандартные списки ролей для Genshin Impact и Honkai: Star Rail. + + Пример: + >> await db.init_default_roles() + """ + # Роли для Genshin Impact с регионами + from configs import all_roles + await self.init_roles(all_roles) + + +# Глобальный экземпляр базы данных +db: BotDatabase = BotDatabase() diff --git a/locales/en/LC_MESSAGES/bot.mo b/locales/en/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..fc24c7e Binary files /dev/null and b/locales/en/LC_MESSAGES/bot.mo differ diff --git a/locales/en/LC_MESSAGES/bot.po b/locales/en/LC_MESSAGES/bot.po new file mode 100644 index 0000000..76c2814 --- /dev/null +++ b/locales/en/LC_MESSAGES/bot.po @@ -0,0 +1,58 @@ +# English translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/messages.pot b/locales/messages.pot new file mode 100644 index 0000000..1bfd65d --- /dev/null +++ b/locales/messages.pot @@ -0,0 +1,56 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.13.1\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/ru/LC_MESSAGES/bot.mo b/locales/ru/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..bc4eb55 Binary files /dev/null and b/locales/ru/LC_MESSAGES/bot.mo differ diff --git a/locales/ru/LC_MESSAGES/bot.po b/locales/ru/LC_MESSAGES/bot.po new file mode 100644 index 0000000..82e0714 --- /dev/null +++ b/locales/ru/LC_MESSAGES/bot.po @@ -0,0 +1,59 @@ +# Russian translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/uk/LC_MESSAGES/bot.mo b/locales/uk/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..914b389 Binary files /dev/null and b/locales/uk/LC_MESSAGES/bot.mo differ diff --git a/locales/uk/LC_MESSAGES/bot.po b/locales/uk/LC_MESSAGES/bot.po new file mode 100644 index 0000000..39c6457 --- /dev/null +++ b/locales/uk/LC_MESSAGES/bot.po @@ -0,0 +1,59 @@ +# Ukrainian translations for Bot Super Project. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the Bot Super Project +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Bot Super Project 0.1\n" +"Report-Msgid-Bugs-To: john@doe-email.com\n" +"POT-Creation-Date: 2024-01-12 16:11+0500\n" +"PO-Revision-Date: 2025-08-10 19:33+0700\n" +"Last-Translator: FULL NAME \n" +"Language: uk\n" +"Language-Team: uk \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: bot/handlers/commands/lang.py:43 +msgid "Выберите язык:" +msgstr "" + +#: bot/handlers/commands/lang.py:50 +#, python-brace-format +msgid "Язык {lang} не поддерживается!" +msgstr "" + +#: bot/handlers/commands/start.py:29 +msgid "Создать пост📔" +msgstr "" + +#: bot/handlers/commands/start.py:30 +msgid "Посмотреть список📋" +msgstr "" + +#: bot/handlers/commands/start.py:31 +msgid "Изменить язык🌐" +msgstr "" + +#: bot/handlers/commands/start.py:35 +#, python-brace-format +msgid "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/main.py b/main.py new file mode 100644 index 0000000..7c04a17 --- /dev/null +++ b/main.py @@ -0,0 +1,56 @@ +from asyncio import run + +from bot import BotInfo, bot, dp, router +from bot.core import server +from bot.middlewares import setup_middlewares +from database import db +from configs import Webhook +from middleware.loggers import setup_logging, loggers + + +async def main() -> None: + """ + Входная точка проекта. + Настройка и запуск бота в режиме webhook или polling. + """ + try: + # Логирование + setup_logging() + + # Cоздание базы данных + await db.init_db() + # Проверка соединения + if not await db.check_connection(): + print("Не удалось подключиться к БД!") + return + await db.init_default_roles() + + # Настройка информации о боте + await BotInfo.setup(bots=bot) + + # Настройка middleware + setup_middlewares( + dp=dp, + bot=bot, + channel_ids=[] # пустой список каналов (можно добавить потом) + ) + + # Подключение маршрутов (роутеров) + dp.include_router(router) + + # Выбор режима работы: webhook или polling + if Webhook.WEBHOOK: + loggers.info(f"Запуск бота @{BotInfo.username} в режиме вебхука...\n") + await server.serve() + + else: + loggers.info(f"Бот @{BotInfo.username} запущен в режиме polling...\n") + await dp.start_polling(bot) + + except Exception as e: + loggers.error(f"🔥 Критическая ошибка при запуске: {e}") + raise + + +if __name__ == "__main__": + run(main()) diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/middleware/loggers/__init__.py b/middleware/loggers/__init__.py new file mode 100644 index 0000000..d668488 --- /dev/null +++ b/middleware/loggers/__init__.py @@ -0,0 +1 @@ +from .logs import * diff --git a/middleware/loggers/logs.py b/middleware/loggers/logs.py new file mode 100644 index 0000000..6cf9a68 --- /dev/null +++ b/middleware/loggers/logs.py @@ -0,0 +1,234 @@ +from sys import stderr +from pathlib import Path +from functools import wraps +from inspect import iscoroutinefunction +from typing import Any, Callable, Optional, TypeVar, cast, Final + +from loguru import logger +from aiogram.types import Message, User + +from configs.config import BotEdit, LogConfig + +# Экспортируемые объекты +__all__ = ('Logger', 'setup_logging', 'loggers', 'log',) + +# Универсальный тип для функций +F: TypeVar = TypeVar('F', bound=Callable[..., Any]) + + +class Logger: + """ + Кастомный логгер с поддержкой декораторов и прямого вызова. + + Attributes: + system_name: Имя системы для логирования + _log_format: Формат логов + """ + _log_format: Final[str] = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' + '{extra[system]}-{extra[log_type]} | ' + '{extra[user]} | {message}' + ) + + def __init__(self, system_name: str = BotEdit.PROJECT_NAME) -> None: + """ + Инициализация логгера. + + :param system_name: Имя системы для логирования + """ + self.system_name = system_name + self._setup_done = False + + def setup(self, start: bool = True) -> None: + """ + Настройка обработчиков Loguru: консоль и файлы. + + :param start: Если True, сразу логирует запуск проекта + """ + if self._setup_done: + return + + # Полная очистка настроек + logger.remove() + + # Создание директории для файловых логов + log_dir: Path = Path(getattr(LogConfig, 'DIR', 'logs')) + log_dir.mkdir(parents=True, exist_ok=True) + + # Консольный лог + if getattr(LogConfig, 'CONSOLE', False): + logger.add( + sink=stderr, + format=self._log_format, + colorize=True, + level='DEBUG', + filter=lambda rec: rec['extra'].get('log_type') != 'DEBUG' + ) + + # Файловые логи + if getattr(LogConfig, 'FILE', False): + # Общий лог + logger.add( + sink=log_dir / 'bot.log', + rotation=getattr(LogConfig, 'ROTATION', '100 MB'), + retention=getattr(LogConfig, 'RETENTION', '7 days'), + format=self._log_format, + level='DEBUG', + enqueue=True, + backtrace=True, + diagnose=True + ) + # Раздельные логи по уровням + for level_name in ['INFO', 'WARNING', 'ERROR', 'DEBUG', 'CRITICAL']: + logger.add( + sink=log_dir / f'{level_name.lower()}.log', + rotation='10 MB', + retention='7 days', + format=self._log_format, + level=level_name, + filter=lambda rec, lvl=level_name: rec['level'].name == lvl, + enqueue=True + ) + + self._setup_done = True + + # Логируем старт + if start: + self.log_entry( + level='INFO', + text='Запуск проекта...', + log_type='START' + ) + + @staticmethod + def format_user(message: Optional[Message] = None) -> str: + """ + Форматирует имя пользователя из объекта Message. + + :param message: Объект aiogram.types.Message + :return: Строка '@username' или 'id' + """ + if message is None or message.from_user is None: + return '@System' + user: User = message.from_user + return f"@{user.username}" if user.username else f"id{user.id}" + + def log_entry( + self, + level: str, + text: str, + log_type: str, + user: Optional[str] = None, + message: Optional[Message] = None + ) -> None: + """ + Основной метод для записи логов. + + :param level: Уровень логирования (например, 'INFO') + :param text: Сообщение для логирования + :param log_type: Кастомный тип лога (например, 'HANDLER') + :param user: Явно указанный пользователь + :param message: Объект Message для извлечения юзера + """ + actual_user: str = user or self.format_user(message) + logger.bind( + system=self.system_name, + user=actual_user, + log_type=log_type + ).log(level, text) + + def log( + self, + level: str = 'INFO', + log_type: str = '', + text: Optional[str] = None + ) -> Callable[[F], F]: + """ + Декоратор для логирования функций. + + :param level: Уровень логирования + :param log_type: Категория лога + :param text: Кастомный текст сообщения + :return: Декорированную функцию + """ + + def decorator(func: F) -> F: + is_coroutine = iscoroutinefunction(func) + action_text = text or f'Вызов {func.__name__}' + + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + message = self._find_message(args) + self.log_entry(level, f"[START] {action_text}", log_type, message=message) + try: + result = func(*args, **kwargs) + self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message) + return result + except Exception as e: + self.log_entry( + 'ERROR', + f"[ERROR] {action_text} | Exception: {e!r}", + log_type, + message=message + ) + raise + + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + message = self._find_message(args) + self.log_entry(level, f"[START] {action_text}", log_type, message=message) + try: + result = await func(*args, **kwargs) + self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message) + return result + except Exception as e: + self.log_entry( + 'ERROR', + f"[ERROR] {action_text} | Exception: {e!r}", + log_type, + message=message + ) + raise + + return cast(F, async_wrapper if is_coroutine else sync_wrapper) + + return decorator + + @staticmethod + def _find_message(args: tuple[Any, ...]) -> Optional[Message]: + """ + Ищет объект Message в аргументах функции. + + :param args: Аргументы функции + :return: Найденный Message или None + """ + return next((arg for arg in args if isinstance(arg, Message)), None) + + # Методы для прямого вызова + def debug(self, text: str, log_type: str = 'BOT', user: Optional[str] = None, + message: Optional[Message] = None) -> None: + self.log_entry('DEBUG', text, log_type, user, message) + + def info(self, text: str, log_type: str = 'BOT', user: Optional[str] = None, + message: Optional[Message] = None) -> None: + self.log_entry('INFO', text, log_type, user, message) + + def warning(self, text: str, log_type: str = 'BOT', user: Optional[str] = None, + message: Optional[Message] = None) -> None: + self.log_entry('WARNING', text, log_type, user, message) + + def error(self, text: str, log_type: str = 'BOT', user: Optional[str] = None, + message: Optional[Message] = None) -> None: + self.log_entry('ERROR', text, log_type, user, message) + + def critical(self, text: str, log_type: str = 'BOT', user: Optional[str] = None, + message: Optional[Message] = None) -> None: + self.log_entry('CRITICAL', text, log_type, user, message) + + +# Создаем глобальный экземпляр логгера +loggers: Logger = Logger() + +# Экспортируемые функции для обратной совместимости +setup_logging = loggers.setup +log = loggers.log diff --git a/middleware/validators/__init__.py b/middleware/validators/__init__.py new file mode 100644 index 0000000..98878dc --- /dev/null +++ b/middleware/validators/__init__.py @@ -0,0 +1,2 @@ +from .email_vld import * +from .url_vld import * diff --git a/middleware/validators/email_vld.py b/middleware/validators/email_vld.py new file mode 100644 index 0000000..c30d0e7 --- /dev/null +++ b/middleware/validators/email_vld.py @@ -0,0 +1,24 @@ +from typing import Optional + +from email_validator import validate_email, EmailNotValidError, ValidatedEmail + +# Настройка экспорта из этого модуля +__all__ = ("valid_email",) + + +def valid_email(e_mail: str) -> Optional[str]: + """ + Валидация почты через библиотеку. + + :param e_mail: Получаемая почта. + :return: Нормализированная почта. + """ + try: + # Провека почты на валидность + email: ValidatedEmail = validate_email(e_mail) + + except EmailNotValidError: + return None + + # Возвращение строки с нормализированной почтой + return email.normalized diff --git a/middleware/validators/url_vld.py b/middleware/validators/url_vld.py new file mode 100644 index 0000000..6da66da --- /dev/null +++ b/middleware/validators/url_vld.py @@ -0,0 +1,42 @@ +from re import Pattern, compile + +# Настройка экспорта +__all__ = ("valid_url", "url_to_text",) + + +def valid_url(url: str) -> bool: + """ + Проверяет, является ли строка валидной ссылкой (URL). + + :param url: Строка для проверки. + :return: True, если строка является валидным URL, иначе False. + """ + url_pattern: Pattern[str] = compile( + r'^(https?://)?' # Протокол (http или https, необязателен) + r'([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}' # Домен + r'(:\d+)?' # Порт (необязателен) + r'(/[-a-zA-Z0-9@:%_+.~#?&/=]*)?$' # Путь, параметры и фрагменты + ) + return bool(url_pattern.match(url)) + + +def url_to_text(text: str, url: str) -> str: + """ + Преобразует текст в HTML ссылку с указанным URL. + + Эта функция генерирует HTML-ссылку с переданным текстом и URL, используя тег `<а>`, и делает ссылку жирной. + + :param text: Текст, который будет отображаться для ссылки. + :param url: URL, который будет привязан к тексту. + :return: Строка с HTML кодом для ссылки, если URL валиден. + :raises ValueError: Если URL невалиден. + """ + try: + if not valid_url(url): # Проверяем, является ли URL валидным + raise ValueError(f"Переданный URL '{url}' невалиден.") + + # Генерация HTML-ссылки + return f'{text}' + + except ValueError as e: + raise e # Перебрасываем ошибку выше для дальнейшей обработки или уведомления diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..0cd7548 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1577 @@ +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + +[[package]] +name = "aiogram" +version = "3.22.0" +description = "Modern and fully asynchronous framework for Telegram Bot API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiogram-3.22.0-py3-none-any.whl", hash = "sha256:1c6eceb078ff62cf0556a5466cf3e7e8119678c26cc56803b7ac5f73633934a8"}, + {file = "aiogram-3.22.0.tar.gz", hash = "sha256:c483f81e37aeea8e7f592c9bd14f6acc80d9b7a2698e296a45bf47ff60a98510"}, +] + +[package.dependencies] +aiofiles = ">=23.2.1,<24.2" +aiohttp = ">=3.9.0,<3.13" +certifi = ">=2023.7.22" +magic-filter = ">=1.0.12,<1.1" +pydantic = ">=2.4.1,<2.12" +typing-extensions = ">=4.7.0,<=5.0" + +[package.extras] +cli = ["aiogram-cli (>=1.1.0,<2.0.0)"] +dev = ["black (>=24.4.2,<24.5.0)", "isort (>=5.13.2,<5.14.0)", "motor-types (>=1.0.0b4,<1.1.0)", "mypy (>=1.10.0,<1.11.0)", "packaging (>=24.1,<25.0)", "pre-commit (>=3.5,<4.0)", "ruff (>=0.5.1,<0.6.0)", "toml (>=0.10.2,<0.11.0)"] +docs = ["furo (>=2024.8.6,<2024.9.0)", "markdown-include (>=0.8.1,<0.9.0)", "pygments (>=2.18.0,<2.19.0)", "pymdown-extensions (>=10.3,<11.0)", "sphinx (>=8.0.2,<8.1.0)", "sphinx-autobuild (>=2024.9.3,<2024.10.0)", "sphinx-copybutton (>=0.5.2,<0.6.0)", "sphinx-intl (>=2.2.0,<2.3.0)", "sphinx-substitution-extensions (>=2024.8.6,<2024.9.0)", "sphinxcontrib-towncrier (>=0.4.0a0,<0.5.0)", "towncrier (>=24.8.0,<24.9.0)"] +fast = ["aiodns (>=3.0.0)", "uvloop (>=0.17.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version < \"3.13\"", "uvloop (>=0.21.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version >= \"3.13\""] +i18n = ["babel (>=2.13.0,<3)"] +mongo = ["motor (>=3.3.2,<3.7.0)", "pymongo (>4.5,<4.11)"] +proxy = ["aiohttp-socks (>=0.8.3,<0.9.0)"] +redis = ["redis[hiredis] (>=5.0.1,<5.3.0)"] +signature = ["cryptography (>=43.0.0)"] +test = ["aresponses (>=2.1.6,<2.2.0)", "pycryptodomex (>=3.19.0,<3.20.0)", "pytest (>=7.4.2,<7.5.0)", "pytest-aiohttp (>=1.0.5,<1.1.0)", "pytest-asyncio (>=0.21.1,<0.22.0)", "pytest-cov (>=4.1.0,<4.2.0)", "pytest-html (>=4.0.2,<4.1.0)", "pytest-lazy-fixture (>=0.6.3,<0.7.0)", "pytest-mock (>=3.12.0,<3.13.0)", "pytest-mypy (>=0.10.3,<0.11.0)", "pytz (>=2023.3,<2024.0)"] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "aiosqlite" +version = "0.21.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0"}, + {file = "aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.1)", "black (==24.3.0)", "build (>=1.2)", "coverage[toml] (==7.6.10)", "flake8 (==7.0.0)", "flake8-bugbear (==24.12.12)", "flit (==3.10.1)", "mypy (==1.14.1)", "ufmt (==2.5.1)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.10.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, + {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "apscheduler" +version = "3.11.0" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, + {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "attrs" +version = "25.3.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.116.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.48.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "frozenlist" +version = "1.7.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, + {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, + {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, + {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, + {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, + {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, + {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, + {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, + {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, + {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, + {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, + {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "magic-filter" +version = "1.0.12" +description = "" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6"}, + {file = "magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9"}, +] + +[package.extras] +dev = ["black (>=22.8.0,<22.9.0)", "flake8 (>=5.0.4,<5.1.0)", "isort (>=5.11.5,<5.12.0)", "mypy (>=1.4.1,<1.5.0)", "pre-commit (>=2.20.0,<2.21.0)", "pytest (>=7.1.3,<7.2.0)", "pytest-cov (>=3.0.0,<3.1.0)", "pytest-html (>=3.1.1,<3.2.0)", "types-setuptools (>=65.3.0,<65.4.0)"] + +[[package]] +name = "multidict" +version = "6.6.4" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f"}, + {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb"}, + {file = "multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f"}, + {file = "multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f"}, + {file = "multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0"}, + {file = "multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f"}, + {file = "multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2"}, + {file = "multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e"}, + {file = "multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24"}, + {file = "multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793"}, + {file = "multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e"}, + {file = "multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a"}, + {file = "multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69"}, + {file = "multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf"}, + {file = "multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92"}, + {file = "multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e"}, + {file = "multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4"}, + {file = "multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17"}, + {file = "multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae"}, + {file = "multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210"}, + {file = "multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a"}, + {file = "multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c"}, + {file = "multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.3.2" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, + {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, + {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, + {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, + {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, + {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, + {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, + {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, + {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, + {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, + {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, + {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, + {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe"}, + {file = "propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1"}, + {file = "propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9"}, + {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, + {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.43-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21ba7a08a4253c5825d1db389d4299f64a100ef9800e4624c8bf70d8f136e6ed"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11b9503fa6f8721bef9b8567730f664c5a5153d25e247aadc69247c4bc605227"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07097c0a1886c150ef2adba2ff7437e84d40c0f7dcb44a2c2b9c905ccfc6361c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cdeff998cb294896a34e5b2f00e383e7c5c4ef3b4bfa375d9104723f15186443"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:bcf0724a62a5670e5718957e05c56ec2d6850267ea859f8ad2481838f889b42c"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win32.whl", hash = "sha256:c697575d0e2b0a5f0433f679bda22f63873821d991e95a90e9e52aae517b2e32"}, + {file = "SQLAlchemy-2.0.43-cp37-cp37m-win_amd64.whl", hash = "sha256:d34c0f6dbefd2e816e8f341d0df7d4763d382e3f452423e752ffd1e213da2512"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00"}, + {file = "sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921"}, + {file = "sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d"}, + {file = "sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d"}, + {file = "sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4e6aeb2e0932f32950cf56a8b4813cb15ff792fc0c9b3752eaf067cfe298496a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f964a05356f4bca4112e6334ed7c208174511bd56e6b8fc86dad4d024d4185"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46293c39252f93ea0910aababa8752ad628bcce3a10d3f260648dd472256983f"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136063a68644eca9339d02e6693932116f6a8591ac013b0014479a1de664e40a"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6e2bf13d9256398d037fef09fd8bf9b0bf77876e22647d10761d35593b9ac547"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:44337823462291f17f994d64282a71c51d738fc9ef561bf265f1d0fd9116a782"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win32.whl", hash = "sha256:13194276e69bb2af56198fef7909d48fd34820de01d9c92711a5fa45497cc7ed"}, + {file = "sqlalchemy-2.0.43-cp38-cp38-win_amd64.whl", hash = "sha256:334f41fa28de9f9be4b78445e68530da3c5fa054c907176460c81494f4ae1f5e"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb5c832cc30663aeaf5e39657712f4c4241ad1f638d487ef7216258f6d41fe7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:11f43c39b4b2ec755573952bbcc58d976779d482f6f832d7f33a8d869ae891bf"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:413391b2239db55be14fa4223034d7e13325a1812c8396ecd4f2c08696d5ccad"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c379e37b08c6c527181a397212346be39319fb64323741d23e46abd97a400d34"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03d73ab2a37d9e40dec4984d1813d7878e01dbdc742448d44a7341b7a9f408c7"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8cee08f15d9e238ede42e9bbc1d6e7158d0ca4f176e4eab21f88ac819ae3bd7b"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win32.whl", hash = "sha256:b3edaec7e8b6dc5cd94523c6df4f294014df67097c8217a89929c99975811414"}, + {file = "sqlalchemy-2.0.43-cp39-cp39-win_amd64.whl", hash = "sha256:227119ce0a89e762ecd882dc661e0aa677a690c914e358f0dd8932a2e8b2765b"}, + {file = "sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc"}, + {file = "sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.47.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"}, + {file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version == \"3.10\""} + +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "uvicorn" +version = "0.35.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[[package]] +name = "yarl" +version = "1.20.1" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, + {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, + {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, + {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, + {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, + {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, + {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, + {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, + {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, + {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, + {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, + {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, + {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d"}, + {file = "yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06"}, + {file = "yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00"}, + {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, + {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "85256276081978abbb83723f994d1f1f7be5e1cf0929ad9311b6f2b135484eea" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..14e955e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "primoexamplebot" +version = "0.1.0" +description = "none" +authors = [ + {name = "admin",email = "inkscaper0349@outlook.com"} +] +license = {text = "MIT License"} +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "aiogram (>=3.22.0,<4.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "uvicorn (>=0.35.0,<0.36.0)", + "fastapi (>=0.116.1,<0.117.0)", + "pydantic-settings (>=2.10.1,<3.0.0)", + "sqlalchemy (>=2.0.43,<3.0.0)", + "babel (>=2.17.0,<3.0.0)", + "aiosqlite (>=0.21.0,<0.22.0)", + "email-validator (>=2.3.0,<3.0.0)", + "apscheduler (>=3.11.0,<4.0.0)", +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry] +package-mode = false + +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.1" +pytest-asyncio = "^1.1.0" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..956b958 Binary files /dev/null and b/requirements.txt differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/database/__init__.py b/tests/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/database/conftest.py b/tests/database/conftest.py new file mode 100644 index 0000000..1b04bb0 --- /dev/null +++ b/tests/database/conftest.py @@ -0,0 +1,106 @@ +import sys +import os +import asyncio +from asyncio import AbstractEventLoop +from datetime import datetime, timedelta, timezone +from typing import AsyncGenerator, Any, Generator + +import pytest +import pytest_asyncio + +from database import BotDatabase, RoleRegion + +# Добавляем путь к корню проекта +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + + +@pytest.fixture(scope="session") +def event_loop() -> Generator[AbstractEventLoop, Any, None]: + """ + Создаёт event loop для асинхронных тестов. + Scope: session, чтобы использовать один loop на всю сессию тестов. + """ + policy = asyncio.get_event_loop_policy() + loop: asyncio.AbstractEventLoop = policy.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def test_db() -> AsyncGenerator[BotDatabase, None]: + """ + Создаёт тестовую базу данных в памяти. + Инициализирует тестовые роли. + """ + db: BotDatabase = BotDatabase("sqlite+aiosqlite:///:memory:", echo=False) + await db.init_db() + + # Инициализируем тестовые роли + test_roles = [ + ("Альбедо", RoleRegion.MONDSTADT), + ("Нахида", RoleRegion.SUMERU), + ("Кафка", RoleRegion.HSR_STAR), + ("Броння", RoleRegion.HSR_STAR), + ("Чжун Ли", RoleRegion.LIYUE) + ] + await db.init_roles(test_roles) + + yield db + await db.dispose() + + +@pytest_asyncio.fixture +async def test_session(test_db: BotDatabase) -> AsyncGenerator: + """ + Создаёт тестовую сессию для работы с БД. + Scope: function (по умолчанию). + """ + async with test_db.session_factory() as session: + yield session + + +@pytest_asyncio.fixture +async def test_user(test_db: BotDatabase) -> int: + """ + Создаёт тестового пользователя. + Возвращает user_id. + """ + user_id: int = 123456789 + await test_db.add_user( + user_id=user_id, + username="test_user", + full_name="Test User" + ) + return user_id + + +@pytest_asyncio.fixture +async def test_user_with_messages(test_db: BotDatabase, test_user: int) -> int: + """ + Создаёт пользователя с тестовыми сообщениями за разные периоды. + Сообщения распределены по месяцам, неделям и дням. + """ + now: datetime = datetime.now(timezone.utc) + + # Даты сообщений: > месяца назад, в текущем месяце, в текущей неделе, сегодня + test_dates: list[datetime] = [ + now - timedelta(days=40), + now - timedelta(days=35), + now - timedelta(days=20), + now - timedelta(days=15), + now - timedelta(days=8), + now - timedelta(days=5), + now - timedelta(days=2), + now - timedelta(hours=12), + now - timedelta(hours=1), + now + ] + + for i, date in enumerate(test_dates): + await test_db.add_message( + user_id=test_user, + message_text=f"Тестовое сообщение {i + 1}", + created_at=date + ) + + return test_user diff --git a/tests/database/test_messages.py b/tests/database/test_messages.py new file mode 100644 index 0000000..ba30d67 --- /dev/null +++ b/tests/database/test_messages.py @@ -0,0 +1,125 @@ +from datetime import datetime, timezone +from typing import List + +import pytest +from sqlalchemy import select, Sequence +from sqlalchemy.ext.asyncio import AsyncSession + +from database import UserMessage, BotDatabase + + +@pytest.mark.asyncio +class TestMessageManagement: + """Тесты для управления сообщениями с полной строгой типизацией""" + + async def test_message_creation( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест создания сообщения. + Проверяет, что сообщение успешно сохраняется в базе и содержит правильные данные. + """ + user_id: int = test_user + test_text: str = "Тестовое сообщение для проверки" + + await test_db.add_message(user_id, test_text) + + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await test_session.execute(stmt) + messages: Sequence[UserMessage] = result.scalars().all() + + assert len(messages) == 1 + assert messages[0].message_text == test_text + assert messages[0].user_id == user_id + assert messages[0].created_at is not None + + async def test_message_with_custom_date( + self, test_db: BotDatabase, test_session: AsyncSession + ) -> None: + """ + Тест добавления сообщения с кастомной датой. + Проверяет, что дата создания сохраняется корректно. + """ + user_id: int = 999888777 + custom_date: datetime = datetime(2024, 1, 15, 12, 30, 0, tzinfo=timezone.utc) + + await test_db.add_user(user_id, "test_user", "Test User") + await test_db.add_message( + user_id=user_id, + message_text="Сообщение с кастомной датой", + created_at=custom_date + ) + + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await test_session.execute(stmt) + messages: Sequence[UserMessage] = result.scalars().all() + + assert len(messages) == 1 + db_date: datetime = messages[0].created_at + if db_date.tzinfo is not None: + db_date = db_date.replace(tzinfo=None) + expected_date: datetime = custom_date.replace(tzinfo=None) + assert db_date == expected_date + + async def test_multiple_messages( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест добавления нескольких сообщений. + Проверяет, что все сообщения корректно сохраняются в базе. + """ + user_id: int = test_user + + # Удаляем старые сообщения + async with test_db.session_factory() as session: + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await session.execute(stmt) + old_messages: Sequence[UserMessage] = result.scalars().all() + for msg in old_messages: + await session.delete(msg) + await session.commit() + + # Добавляем несколько сообщений + for i in range(5): + await test_db.add_message( + user_id=user_id, + message_text=f"Сообщение {i + 1}" + ) + + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await test_session.execute(stmt) + messages: Sequence[UserMessage] = result.scalars().all() + + assert len(messages) == 5 + + async def test_message_ordering( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест проверки порядка сообщений по дате создания. + Сообщения должны возвращаться в порядке возрастания даты. + """ + user_id: int = test_user + + # Очищаем старые сообщения + async with test_db.session_factory() as session: + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await session.execute(stmt) + old_messages: Sequence[UserMessage] = result.scalars().all() + for msg in old_messages: + await session.delete(msg) + await session.commit() + + texts: List[str] = ["Сообщение 1", "Сообщение 2", "Сообщение 3"] + + for text in texts: + await test_db.add_message(user_id, text) + + stmt = select(UserMessage).where(UserMessage.user_id == user_id).order_by(UserMessage.created_at.asc()) + result = await test_session.execute(stmt) + messages: Sequence[UserMessage] = result.scalars().all() + + assert len(messages) == 3 + assert messages[0].message_text == "Сообщение 1" + assert messages[1].message_text == "Сообщение 2" + assert messages[2].message_text == "Сообщение 3" diff --git a/tests/database/test_roles.py b/tests/database/test_roles.py new file mode 100644 index 0000000..7569b04 --- /dev/null +++ b/tests/database/test_roles.py @@ -0,0 +1,207 @@ +import pytest +from typing import List, Dict +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import Role, RoleRegion, BotDatabase + + +@pytest.mark.asyncio +class TestRoleSystem: + """Тесты для системы ролей с полной строгой типизацией""" + + async def test_role_creation(self, test_db: BotDatabase, test_session: AsyncSession) -> None: + """ + Тест создания ролей. + Проверяет, что тестовые роли существуют в базе. + """ + stmt = select(Role) + result = await test_session.execute(stmt) + roles: List[Role] = result.scalars().all() + + assert len(roles) >= 5 + role_names: List[str] = [role.name for role in roles] + assert "Альбедо" in role_names + assert "Нахида" in role_names + assert "Кафка" in role_names + + async def test_assign_role( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест назначения роли пользователю. + Проверяет успешное назначение свободной роли и правильное сохранение в БД. + """ + user_id: int = test_user + + # Освобождаем роль на всякий случай + await test_db.release_role("Альбедо") + + # Назначаем роль + success: bool = await test_db.assign_role("Альбедо", user_id) + assert success, "Не удалось назначить роль" + + # Проверяем, что роль действительно назначена + stmt = select(Role).where(Role.name == "Альбедо") + result = await test_session.execute(stmt) + role: Role = result.scalar_one() + + assert role.occupied_by == user_id + + async def test_assign_occupied_role( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест назначения уже занятой роли. + Проверяет, что нельзя назначить роль, если она уже занята другим пользователем. + """ + user_id: int = test_user + other_user_id: int = 999000111 + + await test_db.release_role("Альбедо") + await test_db.add_user(other_user_id, "other_user", "Other User") + + # Назначаем роль первому пользователю + success_first: bool = await test_db.assign_role("Альбедо", user_id) + assert success_first, "Не удалось назначить роль первому пользователю" + + # Пытаемся назначить ту же роль другому пользователю + success_second: bool = await test_db.assign_role("Альбедо", other_user_id) + assert not success_second, "Нельзя назначить занятую роль" + + async def test_release_role( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест освобождения роли. + Проверяет, что роль успешно освобождается. + """ + user_id: int = test_user + + await test_db.release_role("Нахида") + success_assign: bool = await test_db.assign_role("Нахида", user_id) + assert success_assign + + success_release: bool = await test_db.release_role("Нахида") + assert success_release + + stmt = select(Role).where(Role.name == "Нахида") + result = await test_session.execute(stmt) + role: Role = result.scalar_one() + assert role.occupied_by is None + + async def test_release_unoccupied_role(self, test_db: BotDatabase) -> None: + """ + Тест освобождения свободной роли. + Проверяет, что нельзя освободить уже свободную роль. + """ + await test_db.release_role("Кафка") + success: bool = await test_db.release_role("Кафка") + assert not success, "Нельзя освободить свободную роль" + + async def test_get_user_roles( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест получения ролей пользователя. + Проверяет, что возвращается корректный список назначенных ролей. + """ + user_id: int = test_user + + await test_db.release_role("Альбедо") + await test_db.release_role("Нахида") + + success1: bool = await test_db.assign_role("Альбедо", user_id) + success2: bool = await test_db.assign_role("Нахида", user_id) + assert success1 and success2 + + roles: List[str] = await test_db.get_roles_by_user(user_id) + assert len(roles) == 2 + assert "Альбедо" in roles + assert "Нахида" in roles + + async def test_get_available_roles( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест получения доступных ролей. + Проверяет, что назначенные роли не включены в список свободных. + """ + user_id: int = test_user + + # Освобождаем все роли + for role_name in ["Альбедо", "Нахида", "Кафка", "Броння", "Чжун Ли"]: + await test_db.release_role(role_name) + + # Назначаем одну роль + success: bool = await test_db.assign_role("Альбедо", user_id) + assert success + + available_roles: List[Role] = await test_db.get_available_roles() + role_names: List[str] = [role.name for role in available_roles] + + assert "Альбедо" not in role_names + assert len(available_roles) > 0 + + for role in available_roles: + assert role.occupied_by is None + + async def test_get_occupied_roles( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест получения занятых ролей. + Проверяет, что все назначенные роли возвращаются корректно. + """ + user_id: int = test_user + + await test_db.release_role("Альбедо") + await test_db.release_role("Нахида") + + success1: bool = await test_db.assign_role("Альбедо", user_id) + success2: bool = await test_db.assign_role("Нахида", user_id) + assert success1 and success2 + + occupied_roles: List[Role] = await test_db.get_occupied_roles() + role_names: List[str] = [role.name for role in occupied_roles] + + assert "Альбедо" in role_names + assert "Нахида" in role_names + assert len(occupied_roles) >= 2 + + async def test_region_filter( + self, test_db: BotDatabase, test_session: AsyncSession + ) -> None: + """ + Тест фильтрации ролей по регионам. + Проверяет, что метод возвращает роли только указанного региона. + """ + await test_db.release_role("Альбедо") + mondstadt_roles: List[Role] = await test_db.get_available_roles(RoleRegion.MONDSTADT) + + assert len(mondstadt_roles) == 1 + assert mondstadt_roles[0].name == "Альбедо" + assert mondstadt_roles[0].region == RoleRegion.MONDSTADT + + async def test_region_stats( + self, test_db: BotDatabase, test_session: AsyncSession, test_user: int + ) -> None: + """ + Тест статистики по регионам. + Проверяет, что метод возвращает корректное количество занятых ролей по регионам. + """ + user_id: int = test_user + + await test_db.release_role("Альбедо") + await test_db.release_role("Нахида") + + success1: bool = await test_db.assign_role("Альбедо", user_id) + success2: bool = await test_db.assign_role("Нахида", user_id) + assert success1 and success2 + + stats: Dict[RoleRegion, Dict[str, int]] = await test_db.get_region_stats() + + assert RoleRegion.MONDSTADT in stats + assert RoleRegion.SUMERU in stats + assert stats[RoleRegion.MONDSTADT]["occupied"] == 1 + assert stats[RoleRegion.MONDSTADT]["total"] == 1 diff --git a/tests/database/test_user_stats.py b/tests/database/test_user_stats.py new file mode 100644 index 0000000..448aefe --- /dev/null +++ b/tests/database/test_user_stats.py @@ -0,0 +1,193 @@ +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select, Sequence +from sqlalchemy.ext.asyncio import AsyncSession + +from database import User, UserMessage, BotDatabase + + +@pytest.mark.asyncio +class TestUserStatistics: + """Тесты для статистики пользователей с полной строгой типизацией""" + + async def test_add_user(self, test_db: BotDatabase, test_session: AsyncSession) -> None: + """ + Тест добавления пользователя. + Проверяет, что пользователь создаётся с правильными данными и статусом 'active'. + """ + user_id: int = 111222333 + + await test_db.add_user( + user_id=user_id, + username="new_user", + full_name="New User" + ) + + user: User | None = await test_session.get(User, user_id) + assert user is not None + assert user.username == "new_user" + assert user.status.value == "active" + + async def test_add_message_creates_user( + self, test_db: BotDatabase, test_session: AsyncSession + ) -> None: + """ + Тест, что добавление сообщения создаёт пользователя, если его нет. + Проверяет, что пользователь и сообщение корректно создаются. + """ + user_id: int = 111222333 + + await test_db.add_message( + user_id=user_id, + message_text="Тестовое сообщение" + ) + + user: User | None = await test_session.get(User, user_id) + assert user is not None + assert user.status.value == "active" + + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await test_session.execute(stmt) + messages: Sequence[UserMessage] = result.scalars().all() + + assert len(messages) == 1 + assert messages[0].message_text == "Тестовое сообщение" + + async def test_message_stats_calculation( + self, test_db: BotDatabase, test_user_with_messages: int + ) -> None: + """ + Тест расчёта статистики сообщений пользователя. + Проверяет корректность статистики по дням, неделям, месяцам и общему количеству сообщений. + """ + user_id: int = test_user_with_messages + + # Получаем статистику + day: int + week: int + month: int + total: int + day, week, month, total = await test_db.get_message_stats(user_id) + + assert total >= 10, f"Ожидается минимум 10 сообщений, получено {total}" + assert day >= 0 + assert week >= 0 + assert month >= 0 + assert total >= 0 + assert day <= week <= month <= total + + async def test_message_stats_with_dates( + self, test_db: BotDatabase, test_user: int + ) -> None: + """ + Тест статистики с конкретными известными датами сообщений. + Проверяет подсчёт сообщений за день, неделю, месяц и общее количество. + """ + user_id: int = test_user + now: datetime = datetime.now(timezone.utc) + + # Очищаем старые сообщения + async with test_db.session_factory() as session: + stmt = select(UserMessage).where(UserMessage.user_id == user_id) + result = await session.execute(stmt) + old_messages: Sequence[UserMessage] = result.scalars().all() + for msg in old_messages: + await session.delete(msg) + await session.commit() + + # Создаём сообщения с фиксированными датами + test_messages: list[tuple[datetime, str]] = [ + (now - timedelta(days=45), "45 дней назад"), + (now - timedelta(days=30), "30 дней назад"), + (now - timedelta(days=15), "15 дней назад"), + (now - timedelta(days=7), "7 дней назад"), + (now - timedelta(days=3), "3 дня назад"), + (now - timedelta(hours=6), "6 часов назад"), + (now, "сейчас") + ] + + for date, text in test_messages: + await test_db.add_message(user_id, text, date) + + day: int + week: int + month: int + total: int + day, week, month, total = await test_db.get_message_stats(user_id) + + assert total == 7, f"Ожидалось 7 сообщений, получено {total}" + + day_start: datetime = now.replace(hour=0, minute=0, second=0, microsecond=0) + expected_day: int = sum(1 for date, _ in test_messages if date >= day_start) + assert day == expected_day, f"За день: ожидалось {expected_day}, получено {day}" + + monday: datetime = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + expected_week: int = sum(1 for date, _ in test_messages if date >= monday) + assert week == expected_week, f"За неделю: ожидалось {expected_week}, получено {week}" + + month_start: datetime = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + expected_month: int = sum(1 for date, _ in test_messages if date >= month_start) + assert month == expected_month, f"За месяц: ожидалось {expected_month}, получено {month}" + + async def test_empty_user_stats(self, test_db: BotDatabase) -> None: + """ + Тест статистики для пользователя без сообщений. + Все значения должны быть равны нулю. + """ + user_id: int = 0o00111222 + await test_db.add_user(user_id, "empty_user", "Empty User") + + day: int + week: int + month: int + total: int + day, week, month, total = await test_db.get_message_stats(user_id) + + assert day == 0 + assert week == 0 + assert month == 0 + assert total == 0 + + async def test_user_management(self, test_db: BotDatabase) -> None: + """ + Тест управления пользователями. + Проверяет добавление, назначение админа, бан/разбан и возврат статуса пользователя. + """ + user_id: int = 555666777 + + # Добавление пользователя + await test_db.add_user(user_id, "managed_user", "Managed User") + + async with test_db.session_factory() as session: + user: User | None = await session.get(User, user_id) + assert user is not None + assert user.status.value == "active" + + # Назначение админом + await test_db.set_admin(user_id, True) + async with test_db.session_factory() as session: + user = await session.get(User, user_id) + assert user is not None + assert user.status.value == "admin" + + # Бан пользователя + await test_db.ban_user(user_id) + async with test_db.session_factory() as session: + user = await session.get(User, user_id) + assert user is not None + assert user.status.value == "banned" + + # Разбан + await test_db.unban_user(user_id) + async with test_db.session_factory() as session: + user = await session.get(User, user_id) + assert user is not None + assert user.status.value == "active" + + # Снятие админки + await test_db.set_admin(user_id, False) + async with test_db.session_factory() as session: + user = await session.get(User, user_id) + assert user is not None + assert user.status.value == "active"