From 3c418a97b47724f352e6c0abbabb6554f435a105 Mon Sep 17 00:00:00 2001 From: jze9 Date: Sun, 14 Sep 2025 22:09:39 +0500 Subject: [PATCH] bd - api --- .dockerignore | 82 ++++++ .env.example | 14 + .gitignore | 3 + .python-version | 1 + API_README.md | 357 ++++++++++++++++++++++++ DOCKER_COMMANDS.md | 126 +++++++++ Dockerfile | 63 +++++ README.md | 0 api/__init__.py | 27 ++ api/config.py | 37 +++ api/main.py | 95 +++++++ api/routers.py | 259 +++++++++++++++++ api/run_api.py | 18 ++ api/schemas.py | 123 ++++++++ database/.env.example | 11 + database/README.md | 102 +++++++ database/__init__.py | 37 +++ database/database.py | 180 ++++++++++++ database/example.py | 145 ++++++++++ database/load_images.py | 271 ++++++++++++++++++ database/models.py | 208 ++++++++++++++ database/repositories.py | 185 ++++++++++++ database/requirements.txt | 6 + database/services.py | 358 ++++++++++++++++++++++++ docker-compose.dev.yml | 20 ++ docker-compose.yml | 51 ++++ init.sql | 32 +++ main.py | 6 + pyproject.toml | 16 ++ uv.lock | 571 ++++++++++++++++++++++++++++++++++++++ 30 files changed, 3404 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 API_README.md create mode 100644 DOCKER_COMMANDS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api/__init__.py create mode 100644 api/config.py create mode 100644 api/main.py create mode 100644 api/routers.py create mode 100644 api/run_api.py create mode 100644 api/schemas.py create mode 100644 database/.env.example create mode 100644 database/README.md create mode 100644 database/__init__.py create mode 100644 database/database.py create mode 100644 database/example.py create mode 100644 database/load_images.py create mode 100644 database/models.py create mode 100644 database/repositories.py create mode 100644 database/requirements.txt create mode 100644 database/services.py create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 init.sql create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fcba1ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,82 @@ +# Git +.git +.gitignore +.gitattributes + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov + +# Virtual environments +.env +.venv +env +venv +ENV +env.bak +venv.bak + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Documentation +*.md +!README.md + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Logs +*.log +logs + +# Database +*.db +*.sqlite3 + +# Temporary files +tmp +temp +.tmp +.temp + +# Node modules (если есть) +node_modules +npm-debug.log + +# Images (не включаем картинки в контейнер) +images/ +*.jpg +*.jpeg +*.png +*.gif +*.bmp +*.tiff + +# Bot files (можно исключить файлы бота, если они не нужны в API контейнере) +bot.py +bot2.py \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9db4fa --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Настройки базы данных PostgreSQL +DB_USER=postgres +DB_PASSWORD=password +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=telegram_bot + +# Настройки API +API_HOST=0.0.0.0 +API_PORT=8000 +API_RELOAD=true + +# Настройки логирования +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..476dd3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/venv +/.venv +__pycache__/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/API_README.md b/API_README.md new file mode 100644 index 0000000..a609c38 --- /dev/null +++ b/API_README.md @@ -0,0 +1,357 @@ +# Telegram Bot API + +FastAPI приложение для взаимодействия с Telegram ботом и базой данных PostgreSQL через SQLAlchemy. + +## Структура проекта + +``` +bot-telegram/ +├── api/ # FastAPI приложение +│ ├── __init__.py +│ ├── main.py # Основное приложение +│ ├── routers.py # API маршруты +│ ├── schemas.py # Pydantic схемы +│ └── config.py # Конфигурация +├── database/ # Модули базы данных +│ ├── __init__.py +│ ├── database.py # Подключение к БД +│ ├── models.py # SQLAlchemy модели +│ └── ... +├── pyproject.toml # Зависимости проекта +├── .env.example # Пример переменных окружения +└── README.md +``` + +## Установка и настройка + +### Способ 1: Запуск через Docker (рекомендуется) + +#### 1. Запуск с Docker Compose + +```bash +# Клонируйте репозиторий и перейдите в директорию +cd bot-telegram + +# Запустите все сервисы (API + PostgreSQL) +docker-compose up -d + +# Для просмотра логов +docker-compose logs -f api + +# Остановка сервисов +docker-compose down +``` + +#### 2. Запуск с pgAdmin (опционально) + +```bash +# Запуск с админкой для базы данных +docker-compose --profile admin up -d + +# pgAdmin будет доступен по адресу: http://localhost:5050 +# Email: admin@admin.com +# Password: admin +``` + +#### 3. Сборка только API контейнера + +```bash +# Сборка образа +docker build -t telegram-bot-api . + +# Запуск контейнера (требует запущенный PostgreSQL) +docker run -d \ + --name telegram-bot-api \ + -p 8000:8000 \ + -e DB_HOST=host.docker.internal \ + -e DB_PASSWORD=your_password \ + telegram-bot-api +``` + +### Способ 2: Локальная установка + +#### 1. Установка зависимостей + +```bash +# Используя uv (рекомендуется) +uv sync + +# Или используя pip +pip install -e . +``` + +#### 2. Настройка базы данных + +1. Создайте файл `.env` на основе `.env.example`: +```bash +cp .env.example .env +``` + +2. Отредактируйте `.env` файл с вашими настройками PostgreSQL: +```env +DB_USER=postgres +DB_PASSWORD=your_password +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=telegram_bot +``` + +3. Убедитесь, что PostgreSQL запущен и база данных создана: +```sql +CREATE DATABASE telegram_bot; +``` + +#### 3. Запуск API + +```bash +# Из корневой директории проекта +python run_api.py + +# Или с помощью uvicorn напрямую +uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload +``` + +API будет доступно по адресу: http://localhost:8000 + +## API Endpoints + +### Общие endpoints + +- `GET /` - Корневая информация +- `GET /health` - Проверка состояния приложения +- `GET /docs` - Swagger UI документация (автоматически) +- `GET /redoc` - ReDoc документация (автоматически) + +### Пользователи (`/api/v1/users`) + +- `GET /api/v1/users/` - Получить всех пользователей +- `GET /api/v1/users/{telegram_id}` - Получить пользователя по Telegram ID +- `POST /api/v1/users/` - Создать нового пользователя +- `PUT /api/v1/users/{telegram_id}` - Обновить пользователя +- `DELETE /api/v1/users/{telegram_id}` - Деактивировать пользователя + +### Сообщения (`/api/v1/messages`) + +- `GET /api/v1/messages/` - Получить сообщения с фильтрацией +- `GET /api/v1/messages/{message_id}` - Получить конкретное сообщение +- `POST /api/v1/messages/` - Создать новое сообщение + +### Настройки бота (`/api/v1/settings`) + +- `GET /api/v1/settings/` - Получить все настройки как словарь +- `GET /api/v1/settings/list` - Получить все настройки как список объектов +- `GET /api/v1/settings/{key}` - Получить настройку по ключу +- `POST /api/v1/settings/` - Создать или обновить настройку +- `PUT /api/v1/settings/{key}` - Обновить существующую настройку +- `DELETE /api/v1/settings/{key}` - Удалить настройку + +## Примеры использования + +### Создание пользователя + +```bash +curl -X POST "http://localhost:8000/api/v1/users/" \ + -H "Content-Type: application/json" \ + -d '{ + "telegram_id": 123456789, + "username": "john_doe", + "first_name": "John", + "last_name": "Doe", + "language_code": "en" + }' +``` + +### Создание сообщения + +```bash +curl -X POST "http://localhost:8000/api/v1/messages/" \ + -H "Content-Type: application/json" \ + -d '{ + "telegram_message_id": 12345, + "user_id": 1, + "text": "Привет!", + "message_type": "text" + }' +``` + +### Настройка бота + +```bash +curl -X POST "http://localhost:8000/api/v1/settings/" \ + -H "Content-Type: application/json" \ + -d '{ + "key": "welcome_message", + "value": "Добро пожаловать в наш бот!", + "description": "Приветственное сообщение для новых пользователей" + }' +``` + +## Модели данных + +### User (Пользователь) +- `id` - Внутренний ID +- `telegram_id` - Telegram ID пользователя (уникальный) +- `username` - Username в Telegram +- `first_name` - Имя +- `last_name` - Фамилия +- `language_code` - Код языка (по умолчанию 'ru') +- `is_active` - Активен ли пользователь +- `is_admin` - Является ли администратором +- `created_at` - Дата создания +- `updated_at` - Дата обновления + +### Message (Сообщение) +- `id` - Внутренний ID +- `telegram_message_id` - ID сообщения в Telegram +- `user_id` - ID пользователя (внешний ключ) +- `text` - Текст сообщения +- `message_type` - Тип сообщения (text, photo, document, etc.) +- `created_at` - Дата создания + +### BotSettings (Настройки бота) +- `id` - Внутренний ID +- `key` - Ключ настройки (уникальный) +- `value` - Значение настройки +- `description` - Описание настройки +- `created_at` - Дата создания +- `updated_at` - Дата обновления + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|------------|----------|--------------| +| `DB_USER` | Пользователь PostgreSQL | `postgres` | +| `DB_PASSWORD` | Пароль PostgreSQL | `password` | +| `DB_HOST` | Хост PostgreSQL | `localhost` | +| `DB_PORT` | Порт PostgreSQL | `5432` | +| `DB_NAME` | Имя базы данных | `telegram_bot` | +| `API_HOST` | Хост API сервера | `0.0.0.0` | +| `API_PORT` | Порт API сервера | `8000` | +| `API_RELOAD` | Автоперезагрузка в dev режиме | `true` | +| `LOG_LEVEL` | Уровень логирования | `INFO` | + +## Разработка + +### Структура API + +API организовано по модульному принципу: + +- `main.py` - Основное приложение FastAPI +- `routers.py` - Определения маршрутов и бизнес-логика +- `schemas.py` - Pydantic схемы для валидации данных +- `config.py` - Конфигурация приложения + +### Документация + +FastAPI автоматически генерирует интерактивную документацию: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +### Логирование + +Приложение использует стандартный модуль `logging` Python. Уровень логирования можно настроить через переменную окружения `LOG_LEVEL`. + +## Интеграция с Telegram ботом + +Этот API предназначен для использования Telegram ботом. Бот может: + +1. Регистрировать новых пользователей при первом взаимодействии +2. Сохранять все входящие сообщения +3. Получать и обновлять настройки бота +4. Управлять пользователями (активация/деактивация, права администратора) + +Пример интеграции с aiogram или python-telegram-bot будет добавлен позже. + +## Docker контейнеризация + +### Структура Docker файлов + +- **`Dockerfile`** - Многоступенчатая сборка для оптимизации размера образа +- **`docker-compose.yml`** - Оркестрация API + PostgreSQL + pgAdmin +- **`.dockerignore`** - Исключение ненужных файлов из контекста сборки +- **`init.sql`** - Скрипт инициализации базы данных + +### Docker Compose сервисы + +1. **`postgres`** - PostgreSQL 15 база данных (отдельный контейнер) + - Порт: 5432 + - Данные сохраняются в Docker volume + - Автоматическая проверка готовности + +2. **`app`** - Единое приложение (API + database логика в одном контейнере) + - Порт: 8000 + - Включает FastAPI сервер и все модули для работы с БД + - Подключается к внешней PostgreSQL по сети + - Автоматически ждет готовности БД + +3. **`pgadmin`** - Веб-админка PostgreSQL (опционально) + - Порт: 5050 + - Запускается только с профилем `admin` + +### Команды Docker + +```bash +# Сборка и запуск всех сервисов +docker-compose up --build -d + +# Просмотр логов конкретного сервиса +docker-compose logs -f api +docker-compose logs -f postgres + +# Перезапуск сервиса +docker-compose restart api + +# Остановка и удаление контейнеров +docker-compose down + +# Остановка с удалением volumes (ВНИМАНИЕ: удаляет данные БД) +docker-compose down -v + +# Запуск только определенного сервиса +docker-compose up postgres -d + +# Выполнение команд внутри контейнера +docker-compose exec api bash +docker-compose exec postgres psql -U postgres -d telegram_bot +``` + +### Переменные окружения в Docker + +В `docker-compose.yml` настроены следующие переменные: + +```yaml +environment: + # База данных + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: telegram_bot + DB_USER: postgres + DB_PASSWORD: password + + # API + API_HOST: 0.0.0.0 + API_PORT: 8000 + API_RELOAD: "false" + LOG_LEVEL: INFO +``` + +### Volumes и сети + +- **Volume `postgres_data`** - Постоянное хранение данных PostgreSQL +- **Network `bot_network`** - Изолированная сеть для взаимодействия сервисов + +### Производственное развертывание + +Для продакшена рекомендуется: + +1. Изменить пароли по умолчанию +2. Настроить внешние тома для данных +3. Использовать reverse proxy (nginx) +4. Настроить SSL сертификаты +5. Ограничить CORS origins + +```bash +# Пример для продакшена +docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +``` \ No newline at end of file diff --git a/DOCKER_COMMANDS.md b/DOCKER_COMMANDS.md new file mode 100644 index 0000000..2d0990b --- /dev/null +++ b/DOCKER_COMMANDS.md @@ -0,0 +1,126 @@ +# Docker команды для управления Telegram Bot API + +## Основные команды + +### Запуск и остановка +```bash +# Запуск всех сервисов +docker-compose up -d + +# Запуск с пересборкой +docker-compose up --build -d + +# Запуск с админкой +docker-compose --profile admin up -d + +# Остановка +docker-compose down + +# Остановка с удалением данных +docker-compose down -v +``` + +### Логи и мониторинг +```bash +# Просмотр логов всех сервисов +docker-compose logs + +# Логи конкретного сервиса +docker-compose logs -f app +docker-compose logs -f postgres + +# Статус сервисов +docker-compose ps +``` + +### Управление сервисами +```bash +# Перезапуск сервиса +docker-compose restart app + +# Остановка конкретного сервиса +docker-compose stop app + +# Масштабирование (если нужно) +docker-compose up --scale app=2 -d +``` + +### Работа с контейнерами +```bash +# Выполнение команд в контейнере приложения +docker-compose exec app bash +docker-compose exec app python -c "from database.database import test_db_connection; print(test_db_connection())" + +# Подключение к PostgreSQL +docker-compose exec postgres psql -U postgres -d telegram_bot + +# Просмотр таблиц +docker-compose exec postgres psql -U postgres -d telegram_bot -c "\dt" +``` + +### Отладка и разработка +```bash +# Запуск в режиме разработки (с volume для кода) +docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +# Просмотр переменных окружения в контейнере +docker-compose exec app env + +# Проверка сетевого подключения +docker-compose exec app ping postgres +``` + +### Очистка и обслуживание +```bash +# Удаление неиспользуемых образов +docker image prune + +# Удаление всех остановленных контейнеров +docker container prune + +# Полная очистка Docker системы +docker system prune -a + +# Просмотр использования места +docker system df +``` + +## Готовые скрипты + +### Скрипт для быстрого запуска (start.sh) +```bash +#!/bin/bash +echo "Запуск Telegram Bot API..." +docker-compose up --build -d +echo "API доступно по адресу: http://localhost:8000" +echo "Документация: http://localhost:8000/docs" +``` + +### Скрипт для остановки (stop.sh) +```bash +#!/bin/bash +echo "Остановка Telegram Bot API..." +docker-compose down +echo "Сервисы остановлены" +``` + +### Скрипт для полного сброса (reset.sh) +```bash +#!/bin/bash +echo "ВНИМАНИЕ: Это удалит все данные из базы!" +read -p "Продолжить? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + docker-compose down -v + docker-compose up --build -d + echo "Система сброшена и перезапущена" +fi +``` + +## Полезные ссылки после запуска + +- API: http://localhost:8000 +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc +- Health Check: http://localhost:8000/health +- pgAdmin: http://localhost:5050 (если запущен с профилем admin) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d764d49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Многоступенчатая сборка для оптимизации размера образа + +# Этап 1: Сборка зависимостей +FROM python:3.12-slim AS builder + +WORKDIR /app + +# Устанавливаем системные зависимости для сборки +RUN apt-get update && apt-get install -y \ + gcc \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Устанавливаем uv для управления зависимостями +RUN pip install uv + +# Копируем файлы зависимостей +COPY pyproject.toml uv.lock* ./ + +# Устанавливаем зависимости +RUN uv sync --frozen + +# Этап 2: Производственный образ +FROM python:3.12-slim AS production + +WORKDIR /app + +# Устанавливаем только необходимые runtime зависимости +RUN apt-get update && apt-get install -y \ + libpq5 \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Копируем установленные зависимости из builder stage +COPY --from=builder /app/.venv /app/.venv + +# Копируем исходный код приложения (единое приложение с API и database) +COPY api/ ./api/ +COPY database/ ./database/ +COPY run_api.py ./ +COPY pyproject.toml ./ + +# Создаем непривилегированного пользователя для безопасности +RUN groupadd -r appuser && useradd -r -g appuser -s /bin/bash appuser +RUN chown -R appuser:appuser /app +USER appuser + +# Открываем порт для API +EXPOSE 8000 + +# Устанавливаем переменные окружения +ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 +ENV PATH="/app/.venv/bin:$PATH" + +# Проверка здоровья контейнера +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1 + +# Команда для запуска единого приложения (API + database логика) +CMD ["python", "run_api.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..9003321 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,27 @@ +""" +API пакет для Telegram Bot +""" + +from .main import app +from .routers import users_router, messages_router, settings_router +from .schemas import ( + UserCreate, UserUpdate, UserResponse, + MessageCreate, MessageResponse, + BotSettingCreate, BotSettingUpdate, BotSettingResponse +) + +__version__ = "1.0.0" +__all__ = [ + "app", + "users_router", + "messages_router", + "settings_router", + "UserCreate", + "UserUpdate", + "UserResponse", + "MessageCreate", + "MessageResponse", + "BotSettingCreate", + "BotSettingUpdate", + "BotSettingResponse" +] \ No newline at end of file diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..1a5b029 --- /dev/null +++ b/api/config.py @@ -0,0 +1,37 @@ +""" +Конфигурация для API приложения +""" + +import os +from dotenv import load_dotenv + +# Загрузка переменных окружения из .env файла +load_dotenv() + +class APIConfig: + """Конфигурация API приложения""" + + # Основные настройки + TITLE = "Telegram Bot API" + DESCRIPTION = "API для взаимодействия с Telegram ботом и базой данных" + VERSION = "1.0.0" + + # Настройки сервера + HOST = os.getenv("API_HOST", "0.0.0.0") + PORT = int(os.getenv("API_PORT", "8000")) + RELOAD = os.getenv("API_RELOAD", "true").lower() == "true" + + # Настройки CORS + CORS_ORIGINS = ["*"] # В продакшене стоит ограничить + CORS_CREDENTIALS = True + CORS_METHODS = ["*"] + CORS_HEADERS = ["*"] + + # Настройки логирования + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + + # API префикс + API_V1_PREFIX = "/api/v1" + +# Глобальный экземпляр конфигурации +config = APIConfig() \ No newline at end of file diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..4a9794f --- /dev/null +++ b/api/main.py @@ -0,0 +1,95 @@ +""" +FastAPI приложение для взаимодействия с Telegram ботом +""" + +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from contextlib import asynccontextmanager +import logging +import sys +import os + +# Добавляем путь к корневой директории проекта +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database.database import db, get_db_session, init_database, test_db_connection +from database.models import User, Message, BotSettings +from api.routers import users_router, messages_router, settings_router +from api.schemas import HealthCheckResponse, RootResponse +from api.config import config + +# Настройка логирования +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Управление жизненным циклом приложения""" + # Инициализация при запуске + logger.info("Запуск FastAPI приложения...") + + # Проверка подключения к БД + if not test_db_connection(): + logger.error("Не удалось подключиться к базе данных!") + raise Exception("Database connection failed") + + # Создание таблиц если они не существуют + try: + init_database() + logger.info("База данных инициализирована") + except Exception as e: + logger.error(f"Ошибка инициализации базы данных: {e}") + raise + + yield + + # Очистка при завершении + logger.info("Завершение работы FastAPI приложения...") + db.close() + +# Создание FastAPI приложения +app = FastAPI( + title=config.TITLE, + description=config.DESCRIPTION, + version=config.VERSION, + lifespan=lifespan +) + +# Настройка CORS +app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ORIGINS, + allow_credentials=config.CORS_CREDENTIALS, + allow_methods=config.CORS_METHODS, + allow_headers=config.CORS_HEADERS, +) + +# Подключение роутеров +app.include_router(users_router, prefix=config.API_V1_PREFIX) +app.include_router(messages_router, prefix=config.API_V1_PREFIX) +app.include_router(settings_router, prefix=config.API_V1_PREFIX) + +@app.get("/", response_model=RootResponse) +async def root(): + """Корневой эндпоинт""" + return RootResponse( + message=config.TITLE, + version=config.VERSION, + status="running" + ) + +@app.get("/health", response_model=HealthCheckResponse) +async def health_check(): + """Проверка состояния приложения""" + db_status = test_db_connection() + return HealthCheckResponse( + status="healthy" if db_status else "unhealthy", + database="connected" if db_status else "disconnected" + ) + + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host=config.HOST, port=config.PORT, reload=config.RELOAD) \ No newline at end of file diff --git a/api/routers.py b/api/routers.py new file mode 100644 index 0000000..c238664 --- /dev/null +++ b/api/routers.py @@ -0,0 +1,259 @@ +""" +API маршруты для работы с пользователями, сообщениями и настройками +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import List, Optional +import sys +import os + +# Добавляем путь к корневой директории проекта +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database.database import db +from database.models import User, Message, BotSettings +from api.schemas import ( + UserCreate, UserUpdate, UserResponse, + MessageCreate, MessageResponse, + BotSettingCreate, BotSettingUpdate, BotSettingResponse, + MessageResponseModel, ErrorResponse +) + +# Зависимость для получения сессии БД +def get_db(): + """Получение сессии базы данных""" + session = db.get_session_sync() + try: + yield session + finally: + session.close() + +# Роутер для работы с пользователями +users_router = APIRouter(prefix="/users", tags=["Users"]) + +@users_router.get("/{telegram_id}", response_model=UserResponse) +async def get_user_by_telegram_id(telegram_id: int, db_session: Session = Depends(get_db)): + """Получение пользователя по Telegram ID""" + user = db_session.query(User).filter(User.telegram_id == telegram_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + return user + +@users_router.get("/", response_model=List[UserResponse]) +async def get_all_users( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + is_active: Optional[bool] = Query(default=None), + db_session: Session = Depends(get_db) +): + """Получение списка всех пользователей с фильтрацией""" + query = db_session.query(User) + + if is_active is not None: + query = query.filter(User.is_active == is_active) + + users = query.offset(offset).limit(limit).all() + return users + +@users_router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user(user_data: UserCreate, db_session: Session = Depends(get_db)): + """Создание нового пользователя""" + # Проверяем, существует ли уже пользователь + existing_user = db_session.query(User).filter(User.telegram_id == user_data.telegram_id).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь уже существует" + ) + + # Создаем нового пользователя + new_user = User(**user_data.model_dump()) + db_session.add(new_user) + db_session.commit() + db_session.refresh(new_user) + + return new_user + +@users_router.put("/{telegram_id}", response_model=UserResponse) +async def update_user( + telegram_id: int, + user_data: UserUpdate, + db_session: Session = Depends(get_db) +): + """Обновление данных пользователя""" + user = db_session.query(User).filter(User.telegram_id == telegram_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Обновляем только переданные поля + update_data = user_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(user, field, value) + + db_session.commit() + db_session.refresh(user) + + return user + +@users_router.delete("/{telegram_id}", response_model=MessageResponseModel) +async def delete_user(telegram_id: int, db_session: Session = Depends(get_db)): + """Деактивация пользователя (мягкое удаление)""" + user = db_session.query(User).filter(User.telegram_id == telegram_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + user.is_active = False + db_session.commit() + + return MessageResponseModel(message="Пользователь деактивирован") + +# Роутер для работы с сообщениями +messages_router = APIRouter(prefix="/messages", tags=["Messages"]) + +@messages_router.get("/", response_model=List[MessageResponse]) +async def get_messages( + user_id: Optional[int] = Query(default=None), + message_type: Optional[str] = Query(default=None), + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db_session: Session = Depends(get_db) +): + """Получение сообщений с фильтрацией""" + query = db_session.query(Message) + + if user_id: + query = query.filter(Message.user_id == user_id) + + if message_type: + query = query.filter(Message.message_type == message_type) + + messages = query.order_by(Message.created_at.desc()).offset(offset).limit(limit).all() + return messages + +@messages_router.get("/{message_id}", response_model=MessageResponse) +async def get_message(message_id: int, db_session: Session = Depends(get_db)): + """Получение конкретного сообщения по ID""" + message = db_session.query(Message).filter(Message.id == message_id).first() + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Сообщение не найдено" + ) + return message + +@messages_router.post("/", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +async def create_message(message_data: MessageCreate, db_session: Session = Depends(get_db)): + """Создание нового сообщения""" + # Проверяем, существует ли пользователь + user = db_session.query(User).filter(User.id == message_data.user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + new_message = Message(**message_data.model_dump()) + db_session.add(new_message) + db_session.commit() + db_session.refresh(new_message) + + return new_message + +# Роутер для работы с настройками бота +settings_router = APIRouter(prefix="/settings", tags=["Bot Settings"]) + +@settings_router.get("/", response_model=dict) +async def get_all_settings(db_session: Session = Depends(get_db)): + """Получение всех настроек бота в виде словаря""" + settings = db_session.query(BotSettings).all() + return {setting.key: setting.value for setting in settings} + +@settings_router.get("/list", response_model=List[BotSettingResponse]) +async def get_all_settings_list(db_session: Session = Depends(get_db)): + """Получение всех настроек бота в виде списка объектов""" + settings = db_session.query(BotSettings).all() + return settings + +@settings_router.get("/{key}", response_model=BotSettingResponse) +async def get_setting(key: str, db_session: Session = Depends(get_db)): + """Получение конкретной настройки по ключу""" + setting = db_session.query(BotSettings).filter(BotSettings.key == key).first() + if not setting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Настройка не найдена" + ) + return setting + +@settings_router.post("/", response_model=BotSettingResponse, status_code=status.HTTP_201_CREATED) +async def create_or_update_setting( + setting_data: BotSettingCreate, + db_session: Session = Depends(get_db) +): + """Создание или обновление настройки""" + existing_setting = db_session.query(BotSettings).filter(BotSettings.key == setting_data.key).first() + + if existing_setting: + # Обновляем существующую настройку + update_data = setting_data.model_dump(exclude={"key"}) + for field, value in update_data.items(): + if value is not None: + setattr(existing_setting, field, value) + db_session.commit() + db_session.refresh(existing_setting) + return existing_setting + else: + # Создаем новую настройку + new_setting = BotSettings(**setting_data.model_dump()) + db_session.add(new_setting) + db_session.commit() + db_session.refresh(new_setting) + return new_setting + +@settings_router.put("/{key}", response_model=BotSettingResponse) +async def update_setting( + key: str, + setting_data: BotSettingUpdate, + db_session: Session = Depends(get_db) +): + """Обновление существующей настройки""" + setting = db_session.query(BotSettings).filter(BotSettings.key == key).first() + if not setting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Настройка не найдена" + ) + + update_data = setting_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(setting, field, value) + + db_session.commit() + db_session.refresh(setting) + + return setting + +@settings_router.delete("/{key}", response_model=MessageResponseModel) +async def delete_setting(key: str, db_session: Session = Depends(get_db)): + """Удаление настройки""" + setting = db_session.query(BotSettings).filter(BotSettings.key == key).first() + if not setting: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Настройка не найдена" + ) + + db_session.delete(setting) + db_session.commit() + + return MessageResponseModel(message=f"Настройка '{key}' удалена") \ No newline at end of file diff --git a/api/run_api.py b/api/run_api.py new file mode 100644 index 0000000..1af67d2 --- /dev/null +++ b/api/run_api.py @@ -0,0 +1,18 @@ +""" +Скрипт для запуска API сервера +""" + +import uvicorn +from api.config import config + +if __name__ == "__main__": + print(f"Запуск {config.TITLE} v{config.VERSION}") + print(f"Документация будет доступна по адресу: http://{config.HOST}:{config.PORT}/docs") + + uvicorn.run( + "api.main:app", + host=config.HOST, + port=config.PORT, + reload=config.RELOAD, + log_level=config.LOG_LEVEL.lower() + ) \ No newline at end of file diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..31da66f --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,123 @@ +""" +Pydantic схемы для валидации данных API +""" + +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional +from datetime import datetime + +# Базовые схемы для пользователей + +class UserBase(BaseModel): + """Базовая схема пользователя""" + telegram_id: int = Field(..., description="Telegram ID пользователя") + username: Optional[str] = Field(None, max_length=255, description="Username в Telegram") + first_name: Optional[str] = Field(None, max_length=255, description="Имя пользователя") + last_name: Optional[str] = Field(None, max_length=255, description="Фамилия пользователя") + language_code: str = Field(default="ru", max_length=10, description="Код языка") + +class UserCreate(UserBase): + """Схема для создания пользователя""" + pass + +class UserUpdate(BaseModel): + """Схема для обновления пользователя""" + username: Optional[str] = Field(None, max_length=255) + first_name: Optional[str] = Field(None, max_length=255) + last_name: Optional[str] = Field(None, max_length=255) + language_code: Optional[str] = Field(None, max_length=10) + is_active: Optional[bool] = None + +class UserResponse(UserBase): + """Схема ответа с данными пользователя""" + model_config = ConfigDict(from_attributes=True) + + id: int + is_active: bool + is_admin: bool + created_at: datetime + updated_at: Optional[datetime] = None + +# Базовые схемы для сообщений + +class MessageBase(BaseModel): + """Базовая схема сообщения""" + telegram_message_id: int = Field(..., description="ID сообщения в Telegram") + user_id: int = Field(..., description="ID пользователя в нашей БД") + text: Optional[str] = Field(None, description="Текст сообщения") + message_type: str = Field(default="text", max_length=50, description="Тип сообщения") + +class MessageCreate(MessageBase): + """Схема для создания сообщения""" + pass + +class MessageResponse(MessageBase): + """Схема ответа с данными сообщения""" + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + +# Базовые схемы для настроек бота + +class BotSettingBase(BaseModel): + """Базовая схема настройки бота""" + key: str = Field(..., max_length=255, description="Ключ настройки") + value: Optional[str] = Field(None, description="Значение настройки") + description: Optional[str] = Field(None, description="Описание настройки") + +class BotSettingCreate(BotSettingBase): + """Схема для создания настройки""" + pass + +class BotSettingUpdate(BaseModel): + """Схема для обновления настройки""" + value: Optional[str] = None + description: Optional[str] = None + +class BotSettingResponse(BotSettingBase): + """Схема ответа с данными настройки""" + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: Optional[datetime] = None + +# Схемы для общих ответов API + +class MessageResponseModel(BaseModel): + """Общая схема ответа с сообщением""" + message: str + status: str = "success" + +class ErrorResponse(BaseModel): + """Схема ответа с ошибкой""" + detail: str + status: str = "error" + +class HealthCheckResponse(BaseModel): + """Схема ответа проверки здоровья""" + status: str + database: str + +class RootResponse(BaseModel): + """Схема корневого ответа""" + message: str + version: str + status: str + +# Схемы для пагинации + +class PaginationParams(BaseModel): + """Параметры пагинации""" + limit: int = Field(default=100, ge=1, le=1000, description="Количество элементов на странице") + offset: int = Field(default=0, ge=0, description="Смещение") + +class PaginatedResponse(BaseModel): + """Схема ответа с пагинацией""" + items: list + total: int + limit: int + offset: int + has_next: bool + has_prev: bool \ No newline at end of file diff --git a/database/.env.example b/database/.env.example new file mode 100644 index 0000000..28a6090 --- /dev/null +++ b/database/.env.example @@ -0,0 +1,11 @@ +# Переменные окружения для подключения к PostgreSQL + +# Параметры подключения к базе данных +DB_USER=postgres +DB_PASSWORD=your_password_here +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=telegram_bot + +# Для продакшена можно также использовать полную строку подключения: +# DATABASE_URL=postgresql://username:password@localhost:5432/database_name \ No newline at end of file diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..cbbca0e --- /dev/null +++ b/database/README.md @@ -0,0 +1,102 @@ +# База данных PostgreSQL для Telegram бота + +Этот модуль содержит полную настройку для работы с PostgreSQL через SQLAlchemy. + +## Структура файлов + +- `database.py` - Основные классы для подключения к базе данных +- `models.py` - Модели SQLAlchemy (таблицы) +- `repositories.py` - Репозитории для работы с данными +- `services.py` - Высокоуровневые сервисы +- `example.py` - Примеры использования +- `requirements.txt` - Зависимости +- `.env.example` - Пример файла с переменными окружения + +## Быстрый старт + +### 1. Установка зависимостей + +```bash +pip install -r requirements.txt +``` + +### 2. Настройка базы данных + +Создайте файл `.env` на основе `.env.example`: + +```bash +DB_USER=postgres +DB_PASSWORD=your_password +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=telegram_bot +``` + +### 3. Основное использование + +```python +from database import db_service, setup_database + +# Настройка базы данных (создание таблиц) +setup_database() + +# Регистрация пользователя +user, created = db_service.register_user( + telegram_id=123456789, + username="example_user", + first_name="Иван" +) + +# Сохранение сообщения +message = db_service.save_message( + telegram_message_id=1001, + telegram_user_id=123456789, + text="Привет!" +) + +# Работа с настройками +db_service.set_setting("bot_name", "Мой Бот") +bot_name = db_service.get_setting("bot_name") +``` + +## Основные возможности + +### Модели данных + +- **User** - Пользователи телеграм бота +- **Message** - Сообщения от пользователей +- **BotSettings** - Настройки бота + +### Репозитории + +- **UserRepository** - CRUD операции с пользователями +- **MessageRepository** - CRUD операции с сообщениями +- **SettingsRepository** - CRUD операции с настройками + +### Сервисы + +- **DatabaseService** - Высокоуровневый API для работы с данными + +## Примеры использования + +Запустите `example.py` для демонстрации основных возможностей: + +```bash +python example.py +``` + +## Настройки + +Все настройки подключения к базе данных можно задать через переменные окружения: + +- `DB_USER` - имя пользователя PostgreSQL +- `DB_PASSWORD` - пароль +- `DB_HOST` - хост (по умолчанию localhost) +- `DB_PORT` - порт (по умолчанию 5432) +- `DB_NAME` - имя базы данных + +## Требования + +- Python 3.8+ +- PostgreSQL 10+ +- SQLAlchemy 2.0+ \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..b547a4d --- /dev/null +++ b/database/__init__.py @@ -0,0 +1,37 @@ +""" +Инициализация пакета database +""" + +from .database import Database, DatabaseConfig, db, get_db_session, init_database, test_db_connection +from .models import User, Message, BotSettings +from .repositories import UserRepository, MessageRepository, SettingsRepository +from .services import DatabaseService, db_service, setup_database + +__all__ = [ + # Основные классы + 'Database', + 'DatabaseConfig', + + # Глобальные объекты + 'db', + 'db_service', + + # Функции + 'get_db_session', + 'init_database', + 'test_db_connection', + 'setup_database', + + # Модели + 'User', + 'Message', + 'BotSettings', + + # Репозитории + 'UserRepository', + 'MessageRepository', + 'SettingsRepository', + + # Сервисы + 'DatabaseService', +] \ No newline at end of file diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..54a63d2 --- /dev/null +++ b/database/database.py @@ -0,0 +1,180 @@ +""" +Модуль для подключения к базе данных PostgreSQL через SQLAlchemy +""" + +from sqlalchemy import create_engine, MetaData +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from contextlib import contextmanager +import os +from typing import Generator +import logging + +# Настройка логирования +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Базовый класс для всех моделей +Base = declarative_base() + +class DatabaseConfig: + """Конфигурация базы данных""" + + def __init__(self): + # Параметры подключения к PostgreSQL + self.DB_USER = os.getenv('DB_USER', 'postgres') + self.DB_PASSWORD = os.getenv('DB_PASSWORD', 'password') + self.DB_HOST = os.getenv('DB_HOST', 'localhost') + self.DB_PORT = os.getenv('DB_PORT', '5432') + self.DB_NAME = os.getenv('DB_NAME', 'telegram_bot') + + # Строка подключения + self.DATABASE_URL = f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + # Настройки SQLAlchemy + self.SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, # Проверка соединения перед использованием + 'pool_recycle': 300, # Переподключение каждые 5 минут + 'pool_size': 10, # Размер пула соединений + 'max_overflow': 20, # Максимальное количество дополнительных соединений + 'echo': False, # Логирование SQL запросов (для отладки) + } + +class Database: + """Класс для управления подключением к базе данных""" + + def __init__(self, config: DatabaseConfig = None): + self.config = config or DatabaseConfig() + self.engine = None + self.SessionLocal = None + self._initialize() + + def _initialize(self): + """Инициализация подключения к базе данных""" + try: + # Создание движка SQLAlchemy + self.engine = create_engine( + self.config.DATABASE_URL, + **self.config.SQLALCHEMY_ENGINE_OPTIONS + ) + + # Создание фабрики сессий + self.SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=self.engine + ) + + logger.info("Подключение к базе данных успешно инициализировано") + + except Exception as e: + logger.error(f"Ошибка при инициализации базы данных: {e}") + raise + + def create_tables(self): + """Создание всех таблиц в базе данных""" + try: + Base.metadata.create_all(bind=self.engine) + logger.info("Таблицы успешно созданы") + except Exception as e: + logger.error(f"Ошибка при создании таблиц: {e}") + raise + + def drop_tables(self): + """Удаление всех таблиц из базы данных""" + try: + Base.metadata.drop_all(bind=self.engine) + logger.info("Таблицы успешно удалены") + except Exception as e: + logger.error(f"Ошибка при удалении таблиц: {e}") + raise + + @contextmanager + def get_session(self) -> Generator: + """Контекстный менеджер для получения сессии базы данных""" + session = self.SessionLocal() + try: + yield session + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Ошибка в сессии базы данных: {e}") + raise + finally: + session.close() + + def get_session_sync(self): + """Получение синхронной сессии (не забудьте закрыть!)""" + return self.SessionLocal() + + def test_connection(self) -> bool: + """Проверка подключения к базе данных""" + try: + with self.get_session() as session: + session.execute("SELECT 1") + logger.info("Подключение к базе данных работает корректно") + return True + except Exception as e: + logger.error(f"Ошибка подключения к базе данных: {e}") + return False + + def close(self): + """Закрытие подключения к базе данных""" + if self.engine: + self.engine.dispose() + logger.info("Подключение к базе данных закрыто") + +# Глобальный экземпляр базы данных +db = Database() + +# Удобные функции для использования в приложении +def get_db_session(): + """Получение сессии базы данных для использования в приложении""" + return db.get_session() + +def init_database(): + """Инициализация базы данных (создание таблиц)""" + db.create_tables() + +def test_db_connection(): + """Тестирование подключения к базе данных""" + return db.test_connection() + +def load_image_from_file(file_path: str, alt_text: str = None): + """ + Быстрая загрузка изображения из файла в базу данных + + Args: + file_path: Путь к файлу изображения + alt_text: Альтернативный текст + + Returns: + Image: Объект изображения или None при ошибке + """ + import os + import mimetypes + from database.repositories import ImageRepository + + try: + if not os.path.isfile(file_path): + return None + + filename = os.path.basename(file_path) + mime_type, _ = mimetypes.guess_type(filename) + if not mime_type: + mime_type = 'image/jpeg' + + with open(file_path, 'rb') as f: + image_data = f.read() + + with get_db_session() as session: + image = ImageRepository.create_image( + session, filename, mime_type, image_data, alt_text + ) + session.commit() + return image + + except Exception as e: + logger.error(f"Ошибка загрузки изображения {file_path}: {e}") + return None \ No newline at end of file diff --git a/database/example.py b/database/example.py new file mode 100644 index 0000000..4d56813 --- /dev/null +++ b/database/example.py @@ -0,0 +1,145 @@ +""" +Пример использования базы данных +""" + +import os +import sys +from pathlib import Path + +# Добавляем путь к проекту в PYTHONPATH +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from database.database import db, test_db_connection +from database.services import db_service, setup_database +import logging + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def main(): + """Основная функция для демонстрации работы с базой данных""" + print("🚀 Пример работы с PostgreSQL через SQLAlchemy") + print("=" * 50) + + # 1. Тестирование подключения + print("1. Тестирование подключения к базе данных...") + if test_db_connection(): + print("✅ Подключение успешно!") + else: + print("❌ Ошибка подключения!") + return + + # 2. Настройка базы данных (создание таблиц) + print("\n2. Настройка базы данных...") + if setup_database(): + print("✅ База данных настроена!") + else: + print("❌ Ошибка настройки базы данных!") + return + + # 3. Работа с пользователями + print("\n3. Работа с пользователями...") + + # Регистрация пользователя + user, created = db_service.register_user( + telegram_id=123456789, + username="test_user", + first_name="Иван", + last_name="Иванов" + ) + print(f"Пользователь: {user.first_name} {user.last_name} (@{user.username})") + print(f"Создан новый: {'Да' if created else 'Нет'}") + + # Повторная регистрация (должен вернуть существующего) + user2, created2 = db_service.register_user( + telegram_id=123456789, + username="test_user_updated" + ) + print(f"Повторная регистрация - создан новый: {'Да' if created2 else 'Нет'}") + + # 4. Работа с сообщениями + print("\n4. Работа с сообщениями...") + + # Сохранение сообщения + message = db_service.save_message( + telegram_message_id=1001, + telegram_user_id=123456789, + text="Привет! Это тестовое сообщение", + message_type="text" + ) + print(f"Сообщение сохранено: ID {message.id if message else 'Ошибка'}") + + # Получение сообщений пользователя + messages = db_service.get_user_messages(123456789, limit=10) + print(f"Количество сообщений пользователя: {len(messages)}") + + # 5. Работа с настройками + print("\n5. Работа с настройками...") + + # Установка настройки + db_service.set_setting("welcome_message", "Добро пожаловать в бот!", "Приветственное сообщение") + + # Получение настройки + welcome_msg = db_service.get_setting("welcome_message", "Привет!") + print(f"Приветственное сообщение: {welcome_msg}") + + # 6. Статистика + print("\n6. Статистика...") + user_count = db_service.get_user_count() + print(f"Всего пользователей: {user_count}") + + print("\n✅ Демонстрация завершена успешно!") + +def example_usage_in_telegram_bot(): + """Пример использования в телеграм боте""" + print("\n" + "=" * 50) + print("📱 Пример использования в телеграм боте:") + print("=" * 50) + + # Имитация обработки сообщения от пользователя + telegram_user_id = 987654321 + message_text = "Какая сегодня погода?" + + # 1. Регистрируем/получаем пользователя + user, created = db_service.register_user( + telegram_id=telegram_user_id, + username="weather_user", + first_name="Мария" + ) + + if created: + print(f"🆕 Новый пользователь: {user.first_name}") + # Отправляем приветственное сообщение + welcome_msg = db_service.get_setting("welcome_message", "Привет!") + print(f"Отправляем: {welcome_msg}") + + # 2. Сохраняем сообщение + message = db_service.save_message( + telegram_message_id=2001, + telegram_user_id=telegram_user_id, + text=message_text + ) + + print(f"💬 Сохранено сообщение: '{message_text[:30]}...'") + + # 3. Обновляем активность пользователя + db_service.update_user_info(telegram_user_id, is_active=True) + + print("✅ Обработка сообщения завершена") + +if __name__ == "__main__": + try: + main() + example_usage_in_telegram_bot() + except Exception as e: + logger.error(f"Ошибка в примере: {e}") + print(f"❌ Ошибка: {e}") + finally: + # Закрываем подключение + db.close() + print("\n🔒 Подключение к базе данных закрыто") \ No newline at end of file diff --git a/database/load_images.py b/database/load_images.py new file mode 100644 index 0000000..696cfad --- /dev/null +++ b/database/load_images.py @@ -0,0 +1,271 @@ +""" +Скрипт для загрузки изображений из папки images/ в базу данных +""" + +import os +import mimetypes +from pathlib import Path +from database.database import db, get_db_session +from database.services import ImageService +from database.repositories import ImageRepository +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_image_content_type(filename: str) -> str: + """Определение MIME типа изображения по расширению файла""" + mime_type, _ = mimetypes.guess_type(filename) + + # Если mimetypes не смог определить, делаем по расширению + if not mime_type: + ext = filename.lower().split('.')[-1] + mime_types = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'webp': 'image/webp' + } + mime_type = mime_types.get(ext, 'image/jpeg') + + return mime_type + + +def load_image_to_db(image_path: str, alt_text: str = None) -> bool: + """ + Загрузка одного изображения в базу данных + + Args: + image_path: Путь к файлу изображения + alt_text: Альтернативный текст для изображения + + Returns: + bool: True если загрузка успешна, False если ошибка + """ + try: + # Проверяем, существует ли файл + if not os.path.isfile(image_path): + logger.error(f"Файл не найден: {image_path}") + return False + + # Получаем информацию о файле + filename = os.path.basename(image_path) + content_type = get_image_content_type(filename) + + # Читаем бинарные данные + with open(image_path, 'rb') as f: + image_data = f.read() + + # Проверяем размер файла (ограничение PostgreSQL ~1GB, но лучше меньше) + file_size = len(image_data) + if file_size > 10 * 1024 * 1024: # 10MB лимит + logger.warning(f"Файл слишком большой ({file_size} байт): {filename}") + return False + + # Сохраняем в базу данных + with get_db_session() as session: + # Проверяем, не существует ли уже такой файл + existing_image = ImageRepository.get_image_by_filename(session, filename) + if existing_image: + logger.info(f"Изображение уже существует в БД: {filename}") + return True + + # Создаем новую запись + image = ImageRepository.create_image( + session=session, + filename=filename, + content_type=content_type, + image_data=image_data, + alt_text=alt_text or f"Изображение {filename}" + ) + + session.commit() + logger.info(f"Загружено изображение: {filename} (ID: {image.id}, размер: {file_size} байт)") + return True + + except Exception as e: + logger.error(f"Ошибка при загрузке изображения {image_path}: {e}") + return False + + +def load_images_from_directory(images_dir: str = "images") -> dict: + """ + Загрузка всех изображений из указанной директории + + Args: + images_dir: Путь к директории с изображениями + + Returns: + dict: Статистика загрузки + """ + if not os.path.isdir(images_dir): + logger.error(f"Директория не найдена: {images_dir}") + return {"success": 0, "failed": 0, "skipped": 0} + + # Поддерживаемые форматы изображений + supported_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} + + stats = {"success": 0, "failed": 0, "skipped": 0} + + logger.info(f"Начинаем загрузку изображений из директории: {images_dir}") + + # Проходим по всем файлам в директории + for filename in os.listdir(images_dir): + file_path = os.path.join(images_dir, filename) + + # Пропускаем директории + if os.path.isdir(file_path): + continue + + # Проверяем расширение файла + file_ext = Path(filename).suffix.lower() + if file_ext not in supported_extensions: + logger.info(f"Пропускаем файл неподдерживаемого формата: {filename}") + stats["skipped"] += 1 + continue + + # Загружаем изображение + if load_image_to_db(file_path): + stats["success"] += 1 + else: + stats["failed"] += 1 + + logger.info(f"Загрузка завершена. Успешно: {stats['success']}, " + f"Ошибок: {stats['failed']}, Пропущено: {stats['skipped']}") + + return stats + + +def list_images_in_db(): + """Показать все изображения в базе данных""" + try: + with get_db_session() as session: + images = ImageRepository.get_all_images(session) + + if not images: + logger.info("В базе данных нет изображений") + return + + logger.info(f"Изображения в базе данных ({len(images)} шт.):") + for image in images: + size_kb = image.file_size / 1024 + logger.info(f" ID: {image.id}, Файл: {image.filename}, " + f"Тип: {image.content_type}, Размер: {size_kb:.1f} KB") + + except Exception as e: + logger.error(f"Ошибка при получении списка изображений: {e}") + + +def delete_image_from_db(filename: str = None, image_id: int = None) -> bool: + """ + Удаление изображения из базы данных + + Args: + filename: Имя файла для удаления + image_id: ID изображения для удаления + + Returns: + bool: True если удаление успешно + """ + try: + with get_db_session() as session: + if filename: + image = ImageRepository.get_image_by_filename(session, filename) + elif image_id: + image = ImageRepository.get_image_by_id(session, image_id) + else: + logger.error("Необходимо указать filename или image_id") + return False + + if not image: + logger.error("Изображение не найдено") + return False + + if ImageRepository.delete_image(session, image.id): + session.commit() + logger.info(f"Изображение удалено: {image.filename}") + return True + else: + logger.error("Ошибка при удалении изображения") + return False + + except Exception as e: + logger.error(f"Ошибка при удалении изображения: {e}") + return False + + +if __name__ == "__main__": + """Главная функция для запуска скрипта""" + + print("🖼️ Скрипт загрузки изображений в базу данных") + print("=" * 50) + + # Проверяем подключение к БД + try: + from database.database import test_db_connection + if not test_db_connection(): + print("❌ Ошибка подключения к базе данных!") + exit(1) + print("✅ Подключение к базе данных успешно") + except Exception as e: + print(f"❌ Ошибка: {e}") + exit(1) + + # Меню + while True: + print("\nВыберите действие:") + print("1. Загрузить все изображения из папки images/") + print("2. Загрузить конкретное изображение") + print("3. Показать все изображения в БД") + print("4. Удалить изображение из БД") + print("5. Выход") + + choice = input("\nВведите номер (1-5): ").strip() + + if choice == "1": + stats = load_images_from_directory("images") + print(f"\n📊 Результат: загружено {stats['success']}, " + f"ошибок {stats['failed']}, пропущено {stats['skipped']}") + + elif choice == "2": + image_path = input("Введите путь к изображению: ").strip() + alt_text = input("Введите описание (необязательно): ").strip() or None + if load_image_to_db(image_path, alt_text): + print("✅ Изображение загружено!") + else: + print("❌ Ошибка загрузки") + + elif choice == "3": + list_images_in_db() + + elif choice == "4": + print("Удаление по:") + print("1. Имени файла") + print("2. ID") + sub_choice = input("Выберите (1-2): ").strip() + + if sub_choice == "1": + filename = input("Введите имя файла: ").strip() + if delete_image_from_db(filename=filename): + print("✅ Изображение удалено!") + else: + print("❌ Ошибка удаления") + elif sub_choice == "2": + try: + image_id = int(input("Введите ID: ").strip()) + if delete_image_from_db(image_id=image_id): + print("✅ Изображение удалено!") + else: + print("❌ Ошибка удаления") + except ValueError: + print("❌ Неверный ID") + + elif choice == "5": + print("👋 До свидания!") + break + + else: + print("❌ Неверный выбор") \ No newline at end of file diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..51163b5 --- /dev/null +++ b/database/models.py @@ -0,0 +1,208 @@ +""" +Базовые модели для работы с базой данных +""" + +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, BigInteger, ForeignKey, LargeBinary +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database.database import Base + +class User(Base): + """Модель пользователя телеграм бота""" + + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + telegram_id = Column(BigInteger, unique=True, index=True, nullable=False) + username = Column(String(255), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + language_code = Column(String(10), default='ru') + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Связь с результатами тестов + test_results = relationship("TestResult", back_populates="user") + + def __repr__(self): + return f"" + +class Message(Base): + """Модель сообщений""" + + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + telegram_message_id = Column(BigInteger, nullable=False) + user_id = Column(Integer, nullable=False) # Внешний ключ на users.id + text = Column(Text, nullable=True) + message_type = Column(String(50), default='text') # text, photo, document, etc. + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __repr__(self): + return f"" + +class BotSettings(Base): + """Модель настроек бота""" + + __tablename__ = "bot_settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(255), unique=True, nullable=False) + value = Column(Text, nullable=True) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def __repr__(self): + return f"" + + +# ===== МОДЕЛИ ДЛЯ СИСТЕМЫ ТЕСТИРОВАНИЯ ===== + +class TestCategory(Base): + """Категории тестов""" + + __tablename__ = "test_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), unique=True, nullable=False) # "Тест Климова", "Профориентация" и т.д. + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связь с тестами + tests = relationship("Test", back_populates="category") + + def __repr__(self): + return f"" + + +class Test(Base): + """Конкретные тесты""" + + __tablename__ = "tests" + + id = Column(Integer, primary_key=True, index=True) + category_id = Column(Integer, ForeignKey("test_categories.id"), nullable=False) + title = Column(String(255), nullable=False) # "📘 Тест Климова" + description = Column(Text, nullable=True) + instructions = Column(Text, nullable=True) # Инструкции перед тестом + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связи + category = relationship("TestCategory", back_populates="tests") + questions = relationship("Question", back_populates="test", cascade="all, delete-orphan") + user_results = relationship("TestResult", back_populates="test") + + def __repr__(self): + return f"" + + +class Image(Base): + """Хранение изображений в базе данных""" + + __tablename__ = "images" + + id = Column(Integer, primary_key=True, index=True) + filename = Column(String(255), nullable=False) # Оригинальное имя файла + content_type = Column(String(100), nullable=False) # image/jpeg, image/png и т.д. + file_size = Column(Integer, nullable=False) # Размер в байтах + image_data = Column(LargeBinary, nullable=False) # Бинарные данные изображения + alt_text = Column(String(255), nullable=True) # Альтернативный текст для доступности + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связь с вопросами + questions = relationship("Question", back_populates="image") + + def __repr__(self): + return f"" + + +class Question(Base): + """Вопросы тестов""" + + __tablename__ = "questions" + + id = Column(Integer, primary_key=True, index=True) + test_id = Column(Integer, ForeignKey("tests.id"), nullable=False) + order_index = Column(Integer, nullable=False) # Порядок вопроса в тесте + question_text = Column(Text, nullable=True) # Текст вопроса (может быть пустым если только картинка) + image_id = Column(Integer, ForeignKey("images.id"), nullable=True) # Привязка к изображению + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связи + test = relationship("Test", back_populates="questions") + image = relationship("Image", back_populates="questions") + answer_options = relationship("AnswerOption", back_populates="question", cascade="all, delete-orphan") + user_answers = relationship("UserAnswer", back_populates="question") + + def __repr__(self): + return f"" + + +class AnswerOption(Base): + """Варианты ответов на вопросы""" + + __tablename__ = "answer_options" + + id = Column(Integer, primary_key=True, index=True) + question_id = Column(Integer, ForeignKey("questions.id"), nullable=False) + option_text = Column(Text, nullable=False) # Текст варианта ответа + category = Column(String(255), nullable=True) # Категория для подсчета результатов (например, "Человек-природа") + order_index = Column(Integer, nullable=False) # Порядок варианта (1, 2, 3...) + is_correct = Column(Boolean, default=False) # Для тестов с правильными ответами + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связи + question = relationship("Question", back_populates="answer_options") + user_answers = relationship("UserAnswer", back_populates="selected_option") + + def __repr__(self): + return f"" + + +class TestResult(Base): + """Результаты прохождения тестов пользователями""" + + __tablename__ = "test_results" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + test_id = Column(Integer, ForeignKey("tests.id"), nullable=False) + started_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + is_completed = Column(Boolean, default=False) + result_data = Column(Text, nullable=True) # JSON с результатами по категориям + + # Связи + user = relationship("User", back_populates="test_results") + test = relationship("Test", back_populates="user_results") + answers = relationship("UserAnswer", back_populates="test_result", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + + +class UserAnswer(Base): + """Ответы пользователей на конкретные вопросы""" + + __tablename__ = "user_answers" + + id = Column(Integer, primary_key=True, index=True) + test_result_id = Column(Integer, ForeignKey("test_results.id"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id"), nullable=False) + selected_option_id = Column(Integer, ForeignKey("answer_options.id"), nullable=False) + answered_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Связи + test_result = relationship("TestResult", back_populates="answers") + question = relationship("Question", back_populates="user_answers") + selected_option = relationship("AnswerOption", back_populates="user_answers") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/database/repositories.py b/database/repositories.py new file mode 100644 index 0000000..527db3b --- /dev/null +++ b/database/repositories.py @@ -0,0 +1,185 @@ +""" +Репозитории для работы с моделями базы данных системы тестирования +""" + +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +from database.models import ( + User, Message, BotSettings, Test, TestCategory, Question, + AnswerOption, TestResult, UserAnswer, Image +) +import logging + +logger = logging.getLogger(__name__) + +class UserRepository: + """Репозиторий для работы с пользователями""" + + @staticmethod + def create_user(session: Session, telegram_id: int, username: str = None, + first_name: str = None, last_name: str = None) -> User: + """Создание нового пользователя""" + user = User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name + ) + session.add(user) + session.flush() # Получаем ID без коммита + return user + + @staticmethod + def get_user_by_telegram_id(session: Session, telegram_id: int) -> Optional[User]: + """Получение пользователя по Telegram ID""" + return session.query(User).filter(User.telegram_id == telegram_id).first() + + @staticmethod + def get_user_by_id(session: Session, user_id: int) -> Optional[User]: + """Получение пользователя по ID""" + return session.query(User).filter(User.id == user_id).first() + + @staticmethod + def get_all_users(session: Session, active_only: bool = True) -> List[User]: + """Получение всех пользователей""" + query = session.query(User) + if active_only: + query = query.filter(User.is_active == True) + return query.all() + + @staticmethod + def update_user(session: Session, telegram_id: int, **kwargs) -> Optional[User]: + """Обновление данных пользователя""" + user = UserRepository.get_user_by_telegram_id(session, telegram_id) + if user: + for key, value in kwargs.items(): + if hasattr(user, key): + setattr(user, key, value) + session.flush() + return user + + @staticmethod + def get_or_create_user(session: Session, telegram_id: int, **kwargs) -> tuple[User, bool]: + """Получение или создание пользователя. Возвращает (user, created)""" + user = UserRepository.get_user_by_telegram_id(session, telegram_id) + if user: + return user, False + else: + user = UserRepository.create_user(session, telegram_id, **kwargs) + return user, True + +class MessageRepository: + """Репозиторий для работы с сообщениями""" + + @staticmethod + def create_message(session: Session, telegram_message_id: int, user_id: int, + text: str = None, message_type: str = 'text') -> Message: + """Создание нового сообщения""" + message = Message( + telegram_message_id=telegram_message_id, + user_id=user_id, + text=text, + message_type=message_type + ) + session.add(message) + session.flush() + return message + + @staticmethod + def get_messages_by_user(session: Session, user_id: int, limit: int = 100) -> List[Message]: + """Получение сообщений пользователя""" + return (session.query(Message) + .filter(Message.user_id == user_id) + .order_by(Message.created_at.desc()) + .limit(limit) + .all()) + + @staticmethod + def get_recent_messages(session: Session, limit: int = 100) -> List[Message]: + """Получение последних сообщений""" + return (session.query(Message) + .order_by(Message.created_at.desc()) + .limit(limit) + .all()) + +class SettingsRepository: + """Репозиторий для работы с настройками бота""" + + @staticmethod + def get_setting(session: Session, key: str) -> Optional[str]: + """Получение значения настройки""" + setting = session.query(BotSettings).filter(BotSettings.key == key).first() + return setting.value if setting else None + + @staticmethod + def set_setting(session: Session, key: str, value: str, description: str = None) -> BotSettings: + """Установка значения настройки""" + setting = session.query(BotSettings).filter(BotSettings.key == key).first() + if setting: + setting.value = value + if description: + setting.description = description + else: + setting = BotSettings(key=key, value=value, description=description) + session.add(setting) + session.flush() + return setting + + @staticmethod + def get_all_settings(session: Session) -> List[BotSettings]: + """Получение всех настроек""" + return session.query(BotSettings).all() + + @staticmethod + def delete_setting(session: Session, key: str) -> bool: + """Удаление настройки""" + setting = session.query(BotSettings).filter(BotSettings.key == key).first() + if setting: + session.delete(setting) + session.flush() + return True + return False + + +class ImageRepository: + """Репозиторий для работы с изображениями""" + + @staticmethod + def create_image(session: Session, filename: str, content_type: str, + image_data: bytes, alt_text: str = None) -> Image: + """Создание записи изображения""" + image = Image( + filename=filename, + content_type=content_type, + file_size=len(image_data), + image_data=image_data, + alt_text=alt_text + ) + session.add(image) + session.flush() + return image + + @staticmethod + def get_image_by_id(session: Session, image_id: int) -> Optional[Image]: + """Получение изображения по ID""" + return session.query(Image).filter(Image.id == image_id).first() + + @staticmethod + def get_image_by_filename(session: Session, filename: str) -> Optional[Image]: + """Получение изображения по имени файла""" + return session.query(Image).filter(Image.filename == filename).first() + + @staticmethod + def get_all_images(session: Session) -> List[Image]: + """Получение всех изображений""" + return session.query(Image).all() + + @staticmethod + def delete_image(session: Session, image_id: int) -> bool: + """Удаление изображения""" + image = session.query(Image).filter(Image.id == image_id).first() + if image: + session.delete(image) + return True + return False \ No newline at end of file diff --git a/database/requirements.txt b/database/requirements.txt new file mode 100644 index 0000000..42f5f68 --- /dev/null +++ b/database/requirements.txt @@ -0,0 +1,6 @@ +# Необходимые зависимости для работы с PostgreSQL и SQLAlchemy + +sqlalchemy>=2.0.0 +psycopg2-binary>=2.9.0 # PostgreSQL адаптер для Python +python-dotenv>=1.0.0 # Для работы с переменными окружения +alembic>=1.12.0 # Для миграций базы данных (опционально) \ No newline at end of file diff --git a/database/services.py b/database/services.py new file mode 100644 index 0000000..d7f0d0e --- /dev/null +++ b/database/services.py @@ -0,0 +1,358 @@ +""" +Сервисы для работы с системой тестирования +Основная бизнес-логика для получения вопросов, сохранения ответов и работы с изображениями +""" + +import json +import io +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_ + +from database.models import ( + User, Test, TestCategory, Question, AnswerOption, + TestResult, UserAnswer, Image +) +import logging + +logger = logging.getLogger(__name__) + + +class ImageService: + """Сервис для работы с изображениями в базе данных""" + + @staticmethod + def save_image_to_db( + db: Session, + filename: str, + content_type: str, + image_data: bytes, + alt_text: str = None + ) -> Image: + """ + Сохранение изображения в базу данных + + Args: + db: Сессия базы данных + filename: Имя файла + content_type: MIME тип (image/jpeg, image/png и т.д.) + image_data: Бинарные данные изображения + alt_text: Альтернативный текст + + Returns: + Image: Созданная запись изображения + """ + image = Image( + filename=filename, + content_type=content_type, + file_size=len(image_data), + image_data=image_data, + alt_text=alt_text + ) + + db.add(image) + db.commit() + db.refresh(image) + + return image + + @staticmethod + def get_image_data(db: Session, image_id: int) -> Optional[Tuple[bytes, str, str]]: + """ + Получение данных изображения из БД + + Args: + db: Сессия базы данных + image_id: ID изображения + + Returns: + Tuple[bytes, str, str]: (image_data, content_type, filename) или None + """ + image = db.query(Image).filter(Image.id == image_id).first() + + if image: + return image.image_data, image.content_type, image.filename + + return None + + @staticmethod + def get_image_by_filename(db: Session, filename: str) -> Optional[Image]: + """Получение изображения по имени файла""" + return db.query(Image).filter(Image.filename == filename).first() + + +class TestService: + """Сервис для работы с тестами""" + + @staticmethod + def get_available_tests(db: Session) -> List[Test]: + """Получение всех активных тестов""" + return db.query(Test).filter(Test.is_active == True).all() + + @staticmethod + def get_test_by_title(db: Session, title: str) -> Optional[Test]: + """Получение теста по названию (например, '📘 Тест Климова')""" + return db.query(Test).filter( + and_(Test.title == title, Test.is_active == True) + ).first() + + @staticmethod + def get_test_questions(db: Session, test_id: int) -> List[Question]: + """ + Получение всех вопросов теста в правильном порядке + + Args: + db: Сессия базы данных + test_id: ID теста + + Returns: + List[Question]: Список вопросов с загруженными связями + """ + return db.query(Question).filter( + and_(Question.test_id == test_id, Question.is_active == True) + ).order_by(Question.order_index).all() + + @staticmethod + def start_test(db: Session, user_id: int, test_id: int) -> TestResult: + """ + Начало прохождения теста пользователем + + Args: + db: Сессия базы данных + user_id: ID пользователя + test_id: ID теста + + Returns: + TestResult: Созданная запись результата теста + """ + # Проверяем, есть ли незавершенный тест + existing_result = db.query(TestResult).filter( + and_( + TestResult.user_id == user_id, + TestResult.test_id == test_id, + TestResult.is_completed == False + ) + ).first() + + if existing_result: + return existing_result + + # Создаем новый результат теста + test_result = TestResult( + user_id=user_id, + test_id=test_id, + is_completed=False + ) + + db.add(test_result) + db.commit() + db.refresh(test_result) + + return test_result + + @staticmethod + def save_answer( + db: Session, + test_result_id: int, + question_id: int, + selected_option_id: int + ) -> UserAnswer: + """ + Сохранение ответа пользователя на вопрос + + Args: + db: Сессия базы данных + test_result_id: ID результата теста + question_id: ID вопроса + selected_option_id: ID выбранного варианта ответа + + Returns: + UserAnswer: Созданная запись ответа + """ + # Проверяем, есть ли уже ответ на этот вопрос + existing_answer = db.query(UserAnswer).filter( + and_( + UserAnswer.test_result_id == test_result_id, + UserAnswer.question_id == question_id + ) + ).first() + + if existing_answer: + # Обновляем существующий ответ + existing_answer.selected_option_id = selected_option_id + db.commit() + return existing_answer + + # Создаем новый ответ + user_answer = UserAnswer( + test_result_id=test_result_id, + question_id=question_id, + selected_option_id=selected_option_id + ) + + db.add(user_answer) + db.commit() + db.refresh(user_answer) + + return user_answer + + @staticmethod + def complete_test(db: Session, test_result_id: int) -> TestResult: + """ + Завершение теста и подсчет результатов + + Args: + db: Сессия базы данных + test_result_id: ID результата теста + + Returns: + TestResult: Обновленная запись результата с подсчитанными данными + """ + from datetime import datetime + + test_result = db.query(TestResult).filter( + TestResult.id == test_result_id + ).first() + + if not test_result: + raise ValueError(f"TestResult с ID {test_result_id} не найден") + + # Получаем все ответы пользователя для этого теста + answers = db.query(UserAnswer).filter( + UserAnswer.test_result_id == test_result_id + ).all() + + # Подсчитываем результаты по категориям + category_scores = {} + + for answer in answers: + option = db.query(AnswerOption).filter( + AnswerOption.id == answer.selected_option_id + ).first() + + if option and option.category: + category = option.category + category_scores[category] = category_scores.get(category, 0) + 1 + + # Сохраняем результаты в JSON формате + test_result.result_data = json.dumps(category_scores, ensure_ascii=False) + test_result.completed_at = datetime.now() + test_result.is_completed = True + + db.commit() + db.refresh(test_result) + + return test_result + + @staticmethod + def get_test_results(db: Session, test_result_id: int) -> Dict[str, Any]: + """ + Получение и форматирование результатов теста + + Args: + db: Сессия базы данных + test_result_id: ID результата теста + + Returns: + Dict: Словарь с результатами и процентами + """ + test_result = db.query(TestResult).filter( + TestResult.id == test_result_id + ).first() + + if not test_result or not test_result.result_data: + return {} + + # Парсим результаты + category_scores = json.loads(test_result.result_data) + + # Получаем максимальные возможные баллы для каждой категории + questions = TestService.get_test_questions(db, test_result.test_id) + max_scores = {} + + for question in questions: + for option in question.answer_options: + if option.category: + max_scores[option.category] = max_scores.get(option.category, 0) + 1 + + # Вычисляем проценты + results = {} + for category, score in category_scores.items(): + max_score = max_scores.get(category, 1) + percentage = (score / max_score) * 100 + results[category] = { + 'score': score, + 'max_score': max_score, + 'percentage': round(percentage, 1) + } + + return results + + @staticmethod + def get_user_test_history(db: Session, user_id: int) -> List[TestResult]: + """Получение истории тестов пользователя""" + return db.query(TestResult).filter( + and_(TestResult.user_id == user_id, TestResult.is_completed == True) + ).order_by(TestResult.completed_at.desc()).all() + + +class QuestionService: + """Сервис для работы с вопросами""" + + @staticmethod + def get_question_with_options(db: Session, question_id: int) -> Optional[Question]: + """ + Получение вопроса со всеми вариантами ответов и изображением + + Args: + db: Сессия базы данных + question_id: ID вопроса + + Returns: + Question: Вопрос с загруженными связями или None + """ + return db.query(Question).filter(Question.id == question_id).first() + + @staticmethod + def format_question_for_bot(db: Session, question: Question) -> Dict[str, Any]: + """ + Форматирование вопроса для отправки в бот + + Args: + db: Сессия базы данных + question: Объект вопроса + + Returns: + Dict: Сформатированные данные вопроса + """ + # Получаем варианты ответов + options = db.query(AnswerOption).filter( + AnswerOption.question_id == question.id + ).order_by(AnswerOption.order_index).all() + + # Получаем данные изображения, если есть + image_data = None + if question.image_id: + image_info = ImageService.get_image_data(db, question.image_id) + if image_info: + image_data = { + 'data': image_info[0], + 'content_type': image_info[1], + 'filename': image_info[2] + } + + return { + 'id': question.id, + 'text': question.question_text, + 'order_index': question.order_index, + 'image': image_data, + 'options': [ + { + 'id': option.id, + 'text': option.option_text, + 'category': option.category, + 'order_index': option.order_index + } + for option in options + ] + } \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e08c672 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +# Docker Compose override для разработки +# Используйте: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d + +version: '3.8' + +services: + app: + # В режиме разработки монтируем исходный код для hot reload + volumes: + - ./api:/app/api + - ./database:/app/database + - ./run_api.py:/app/run_api.py + + # Включаем автоперезагрузку и отладку в dev режиме + environment: + API_RELOAD: "true" + LOG_LEVEL: DEBUG + + # Переопределяем команду для dev режима с автоперезагрузкой + command: ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..45a5425 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + # База данных PostgreSQL (отдельный сервис) + postgres: + image: postgres:15 + container_name: telegram_bot_db + environment: + POSTGRES_DB: telegram_bot + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - bot_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -q -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + # Единое приложение (API + database логика в одном контейнере) + app: + build: . + container_name: telegram_bot_app + environment: + # Настройки подключения к внешней PostgreSQL + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: telegram_bot + DB_USER: postgres + DB_PASSWORD: password + + # Настройки API сервера + API_HOST: 0.0.0.0 + API_PORT: 8000 + API_RELOAD: "false" + LOG_LEVEL: INFO + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + networks: + - bot_network + restart: unless-stopped + + diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..99eeb52 --- /dev/null +++ b/init.sql @@ -0,0 +1,32 @@ +-- Скрипт инициализации базы данных для Telegram Bot API +-- Этот файл автоматически выполняется при первом запуске PostgreSQL контейнера + +-- Создание базы данных (если еще не создана) +-- В docker-compose база уже создается через POSTGRES_DB + +-- Создание пользователя для приложения (если нужен отдельный пользователь) +-- DO $$ +-- BEGIN +-- IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'bot_user') THEN +-- CREATE USER bot_user WITH PASSWORD 'bot_password'; +-- GRANT CONNECT ON DATABASE telegram_bot TO bot_user; +-- GRANT USAGE ON SCHEMA public TO bot_user; +-- GRANT CREATE ON SCHEMA public TO bot_user; +-- END IF; +-- END +-- $$; + +-- Подключаемся к нужной базе данных +\c telegram_bot; + +-- Создаем расширения если нужны +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Примечание: Таблицы будут созданы автоматически через SQLAlchemy +-- при первом запуске API приложения + +-- Можно добавить начальные данные, если нужно +-- INSERT INTO bot_settings (key, value, description) VALUES +-- ('welcome_message', 'Добро пожаловать в наш бот!', 'Приветственное сообщение для новых пользователей'), +-- ('max_messages_per_day', '100', 'Максимальное количество сообщений от пользователя в день') +-- ON CONFLICT (key) DO NOTHING; \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ff83d84 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from bot-telegram!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..872912b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "bot-telegram" +version = "0.1.0" +description = "Telegram bot with FastAPI backend" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.104.1", + "uvicorn[standard]>=0.24.0", + "sqlalchemy>=2.0.23", + "psycopg2-binary>=2.9.7", + "alembic>=1.12.1", + "pydantic>=2.5.0", + "python-multipart>=0.0.6", + "python-dotenv>=1.0.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9f61a94 --- /dev/null +++ b/uv.lock @@ -0,0 +1,571 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "bot-telegram" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.12.1" }, + { name = "fastapi", specifier = ">=0.104.1" }, + { name = "psycopg2-binary", specifier = ">=2.9.7" }, + { name = "pydantic", specifier = ">=2.5.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "sqlalchemy", specifier = ">=2.0.23" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, + { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, + { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, + { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, + { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, + { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, + { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, + { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +]