diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6ac5f75 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Build and Deploy + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Login to Docker Hub + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Build and Push bot + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest -f app/bot/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-bot:latest + + - name: Build and Push admin + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest -f app/admin/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-admin:latest + + - name: Build and Push poller + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest -f app/poller/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-poller:latest + + - name: Build and Push migrator + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest -f app/admin/Dockerfile . + docker push ${{ secrets.DOCKER_USERNAME }}/jeopardy-migrator:latest + + - name: Deploy on remote VPS + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USER }} + key: ${{ secrets.DEPLOY_KEY }} + script: | + cd jeopardybot + git pull + docker-compose -f prod.docker-compose.yml pull + docker-compose -f prod.docker-compose.yml up -d diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3a4dedc..2573fb2 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,13 +1,37 @@ -name: Check homework +name: Check Project + on: [push] + jobs: - lint: + ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 - - name: Install ruff - run: pip install ruff==0.4.2 - - run: ruff format --check && ruff check --no-fix + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv pip install --system -r requirements.txt + - name: Check by ruff + run: ruff check . + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install dependencies + run: uv pip install --system -r requirements.txt + - name: Check by mypy + run: mypy . + diff --git a/.gitignore b/.gitignore index 1c7d1e5..f74552f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,3 @@ -# Created by https://www.toptal.com/developers/gitignore/api/python -# Edit at https://www.toptal.com/developers/gitignore?templates=python - ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ @@ -30,12 +27,6 @@ share/python-wheels/ *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt @@ -86,33 +77,6 @@ target/ profile_default/ ipython_config.py -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -156,13 +120,6 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e7cbd6d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.5 + hooks: + - id: ruff + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + language: system \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0526c7c --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +migrations: + alembic revision --autogenerate + +migrate: + alembic upgrade head + +db: migrate migrations migrate + +dumpdata: + python -m app.fixtures.fixtures dump ./app/fixtures/data.json + +loaddata: + python -m app.fixtures.fixtures load ./app/fixtures/data.json + +down: + docker compose down + +up: + docker compose up --build -d + +reset: down up migrate loaddata + +ruff: + ruff check . + +mypy: + mypy . + +lint: ruff mypy \ No newline at end of file diff --git a/README.md b/README.md index 7740ca6..4a12232 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,3 @@ -# Памятка по работе с проектом +Схема состояний: -## Начало работы с проектом -Для начала работы с проектом необходимо создать репозиторий по [шаблону](https://github.com/ktsstudio/backend-school-template-project). Для этого используйте кнопку "Use this template". - -image - -После этого его можно локально клонировать себе на компьютер: - -``` sh -git clone <ссылка на репозиторий> -``` - ---- -## Ветки dev и main -После того как скопируете репозиторий, скорее всего, вы будете находиться в main-ветке. - -Как правило, ветка `main` (`master`) содержит в себе _production-ready_ код, т.е. именно из этой ветки проект будет катиться. Поэтому сама разработка из этой ветки обычно не ведется, туда делают merge финальных изменений. - -Создадим ветку dev: -``` sh -git checkout -b dev -``` - -Dev — чаще всего общая тестовая ветка. От `dev-ветки` ответвляются `feature-ветки`, в которые добавляется новая функциональность, тестируется, проходит ревью и "сливается" в `dev-ветку`. - -Создадим `feature`-ветку: -``` sh -git checkout -b <<название ветки>> -``` - -После чего создается [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). - -`Pull request (PR)` позволяет другим разработчикам провести ревью и оставить комментарии к написанному коду, прежде чем проверять его и делать релиз. - - ---- -## Работа в репозитории - -### Gitignore - -Файл .gitignore содержит информацию о том, какие файлы не следует сохранять в удаленный репозиторий – локальные конфиги, файлы библиотек, специфичные файлы IDE или операционной системы. - -Самый простой способ составить подходящий `.gitignore` файл — воспользоваться ресурсом [gitignore.io](https://www.toptal.com/developers/gitignore/) - - -Обратите внимание, что в шаблоне проекта уже присутствует файл `.gitignore`. Остается убедиться, что у вас нет каких-то дополнительных файлов, которые следует туда добавить. - ---- -### Виртуальное окружение - -#### Создание виртуального окружения - -Виртуальное окружение позволяет разделять проекты, зависимости и даже версии языка. - -**Пример.** Есть два проекта. Один использует библиотеку `example` версии 1, второй – версии 2. Они не могут существовать одновременно, и версии могут конфликтовать из-за каких-то других зависимостей. Поэтому мы создаем два виртуальных окружения, каждое для своего проекта. `PyCharm` может создавать их автоматически. `Python` будет видеть только библиотеки из своего виртуального окружения, что существенно облегчит сосуществование множества проектов на одном компьютере. - -**Создадим:** -``` sh -python -m venv <название окружения> -``` - -> Принято называть окружение `env` или `venv` -– `([virtual] environment)`. - -Задать версию языка для виртуального окружения, например 3.12: -``` -python3.12 -m venv <название окружения> -``` - -> Обратите внимание, что для этого нужно чтобы эта версия была установлена в системе. - - -#### Активация -**Для Linux/MacOS:** -``` sh -source <название окружения>/bin/activate -``` - -**Для Windows:** -``` sh -<название окружения>\Scripts\activate.bat -``` - ---- -### Зависимости -В файл `requirements.txt` принято записывать зависимости проекта – список библиотек и их версий, без которых проект не сможет запуститься. Добавляя в проект использование новой библиотеки, обязательно нужно записать ее в `requirements.txt`. При релизе зависимости устанавливаются из же этого файла. - -Пример файла: -```requirements.txt -aiohttp==3.8.1 -black==22.6.0 -freezegun==1.2.1 -pytest==7.1.2 -pytest-aiohttp==1.0.4 -``` - -Если в новой версии из библиотеки будет удалено что-то важное для проекта, то ничего не сломается, потому что мы фиксируемся на старой версии. В дальнейшем мы можем вручную обновить версию и сразу проследить, что при обновлении все работает как нужно. Либо что-то починить, если сломалось. - -Установить все необходимые библиотеки можно при помощи команды… -```sh -pip install <библиотека> -``` - -…либо: -```sh -pip install -r requirements.txt -``` - -Лучше всего ставить библиотеки в виртуальное окружение. - ---- -### Ruff -[Библиотека](https://docs.astral.sh/ruff/) для автоматического форматирования кода и проверки его на ошибки. Рекомендуется использовать, чтобы код был читаемым и соответствовал `pep-8`. Для применения потребуется поставить библиотеку в виртуальное окружение. - -**Отформатировать код:** -```sh -ruff format -``` - -**Проверить код** -```sh -ruff check --fix -``` - -В файле `pyproject.toml` можно сконфигурировать библиотеку. - -Например: -```toml -[tool.ruff] -line-length = 80 -indent-width = 4 -target-version = "py312" -``` - -> Обратите внимание, что в `pyproject.toml` для вас уже добавлена рекомендуемая конфигурация. -> По договоренности с вашим ментором конфигурацию можно отредактировать. +Схема базы данных: \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..b7d2fa7 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,51 @@ +[alembic] +script_location = app/core/database/migrations +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +prepend_sys_path = . +version_path_separator = os + +[post_write_hooks] +hooks = ruff_format, ruff_fix + +ruff_format.type = exec +ruff_format.executable = ruff +ruff_format.options = format REVISION_SCRIPT_FILENAME + +ruff_fix.type = exec +ruff_fix.executable = ruff +ruff_fix.options = check --fix REVISION_SCRIPT_FILENAME + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py index 9f2c7c5..8b13789 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,11 +1 @@ -import os - -def read_version(): - current_dir = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(current_dir, "..", "VERSION")) as f: - return f.read().strip() - - -__appname__ = "kts_backend" -__version__ = read_version() diff --git a/app/admin/Dockerfile b/app/admin/Dockerfile new file mode 100644 index 0000000..320aebd --- /dev/null +++ b/app/admin/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +CMD python -m app.admin.main \ No newline at end of file diff --git a/app/users/__init__.py b/app/admin/__init__.py similarity index 100% rename from app/users/__init__.py rename to app/admin/__init__.py diff --git a/app/admin/accessor.py b/app/admin/accessor.py new file mode 100644 index 0000000..178df7f --- /dev/null +++ b/app/admin/accessor.py @@ -0,0 +1,86 @@ +from typing import cast + +from sqlalchemy import insert, select +from sqlalchemy.orm import selectinload + +from app.admin.models import AdminModel +from app.bot.models import QuestionModel, ThemeModel +from app.core.accessor_base import BaseAccessor + + +class AdminAccessor(BaseAccessor): + async def connect(self, *args, **kwargs) -> None: + await self.create_admin( + email=self.app.config.admin.login, + password=self.app.config.admin.password, + ) + + async def get_by_email(self, email: str) -> AdminModel | None: + query = select(AdminModel).filter(AdminModel.email == email) + return await self.scalar(query) + + async def get_by_id(self, admin_id: int) -> AdminModel | None: + query = select(AdminModel).filter(AdminModel.id == admin_id) + return (await self.execute(query)).scalar_one_or_none() + + async def create_admin(self, email: str, password: str) -> AdminModel: + async with self.app.database.session() as session: + query = select(AdminModel).where(AdminModel.email == email) + existing = (await session.execute(query)).scalar_one_or_none() + + if existing: + return existing + + admin = AdminModel(email=email) + admin.set_password(password) + session.add(admin) + await session.commit() + return admin + + +class ThemeAccessor(BaseAccessor): + async def get_theme_by_id(self, theme_id: int) -> ThemeModel | None: + exp = ( + select(ThemeModel) + .where(ThemeModel.id == theme_id) + .options(selectinload(ThemeModel.questions)) + ) + return await self.scalar(exp) + + async def get_all_themes(self) -> list[ThemeModel]: + exp = select(ThemeModel).options(selectinload(ThemeModel.questions)) + return list(await self.scalars(exp)) + + async def get_all_questions(self) -> list[QuestionModel]: + exp = select(QuestionModel) + return list(await self.scalars(exp)) + + async def create_theme(self, title: str) -> ThemeModel: + exp = insert(ThemeModel).values(title=title).returning(ThemeModel) + return cast(ThemeModel, await self.scalar(exp)) + + async def get_question_by_id(self, question_id: int) -> QuestionModel | None: + exp = ( + select(QuestionModel) + .where(QuestionModel.id == question_id) + .options(selectinload(QuestionModel.theme)) + ) + return await self.scalar(exp) + + async def get_questions_by_theme(self, theme_id: int) -> list[QuestionModel]: + exp = ( + select(QuestionModel) + .where(QuestionModel.theme_id == theme_id) + .options(selectinload(QuestionModel.theme)) + ) + return list(await self.scalars(exp)) + + async def create_question( + self, text: str, answer: str, hard_level: int, theme_id: int + ) -> QuestionModel: + exp = ( + insert(QuestionModel) + .values(text=text, answer=answer, hard_level=hard_level, theme_id=theme_id) + .returning(QuestionModel) + ) + return cast(QuestionModel, await self.scalar(exp)) diff --git a/app/admin/main.py b/app/admin/main.py new file mode 100644 index 0000000..5bd49a6 --- /dev/null +++ b/app/admin/main.py @@ -0,0 +1,11 @@ +from aiohttp.web import run_app + +from app.app import setup_app + +if __name__ == "__main__": + app = setup_app() + + app.database.connect() + app.on_startup.append(app.accessors.admin_accessor.connect) + + run_app(app) \ No newline at end of file diff --git a/app/admin/mixins.py b/app/admin/mixins.py new file mode 100644 index 0000000..46c2658 --- /dev/null +++ b/app/admin/mixins.py @@ -0,0 +1,21 @@ +from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp_session import get_session +from app.app import app + + +class AuthRequiredMixin: + async def _iter(self): + session = await get_session(request=self.request) + admin_email = session.get("admin_email", None) + + if admin_email is None: + raise HTTPUnauthorized() + + admin = await app.accessors.admin_accessor.get_by_email(admin_email) + + if admin is None: + raise HTTPUnauthorized() + + self.request.admin = admin + + return await super()._iter() diff --git a/app/admin/models.py b/app/admin/models.py new file mode 100644 index 0000000..5018c02 --- /dev/null +++ b/app/admin/models.py @@ -0,0 +1,19 @@ +from hashlib import sha256 + +from sqlalchemy import Column, Integer, String + +from app.core.database.sqlalchemy_base import BaseModel + + +class AdminModel(BaseModel): + __tablename__ = "admin_model" + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String, nullable=False, unique=True) + password = Column(String, nullable=True) + + def check_password(self, password: str) -> bool: + return self.password == sha256(password.encode("utf-8")).hexdigest() + + def set_password(self, password: str) -> None: + self.password = sha256(password.encode("utf-8")).hexdigest() diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..0440dab --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,18 @@ +import typing + +if typing.TYPE_CHECKING: + from app.app import Application + + +def setup_routes(app: "Application"): + from app.admin.views import ( + AdminCurrentView, + AdminLoginView, + QuestionsView, + ThemesView, + ) + + app.router.add_view("/admin/current", AdminCurrentView) + app.router.add_view("/admin/login", AdminLoginView) + app.router.add_view("/admin/questions", QuestionsView) + app.router.add_view("/admin/themes", ThemesView) diff --git a/app/admin/schemes.py b/app/admin/schemes.py new file mode 100644 index 0000000..e1a3279 --- /dev/null +++ b/app/admin/schemes.py @@ -0,0 +1,61 @@ +from pydantic import BaseModel + + +class AdminSchema(BaseModel): + email: str + password: str + + class Config: + from_attributes = True + + +class AdminResponseSchema(BaseModel): + id: int + email: str + + class Config: + from_attributes = True + + +class OkResponseSchema(BaseModel): + status: str + data: dict + + class Config: + from_attributes = True + + +class ThemeSchema(BaseModel): + title: str + + class Config: + from_attributes = True + + +class ThemeResponseSchema(BaseModel): + id: int + title: str + + class Config: + from_attributes = True + + +class QuestionSchema(BaseModel): + text: str + answer: str + hard_level: int + theme_id: int + + class Config: + from_attributes = True + + +class QuestionResponseSchema(BaseModel): + id: int + text: str + answer: str + hard_level: int + theme_id: int + + class Config: + from_attributes = True diff --git a/app/admin/utils.py b/app/admin/utils.py new file mode 100644 index 0000000..c4ebc31 --- /dev/null +++ b/app/admin/utils.py @@ -0,0 +1,49 @@ +from functools import wraps + +from aiohttp.web import json_response as aiohttp_json_response +from aiohttp.web_response import Response +from pydantic import BaseModel, ValidationError + + +def json_response(data: BaseModel | None = None, status: str = "ok") -> Response: + data = {} if data is None else data.model_dump() + + return aiohttp_json_response( + data={ + "status": status, + "data": data, + }, + ) + + +def error_json_response( + http_status: int, + status: str | None = None, + message: str | None = None, + data: dict | None = None, +): + return aiohttp_json_response( + status=http_status, + data={ + "status": status or 400, + "message": message, + "data": data, + }, + ) + + +def validate_json(model: type[BaseModel]): + def decorator(handler): + @wraps(handler) + async def wrapper(self, *args, **kwargs): + try: + json_data = await self.request.json() + validated = model(**json_data) + self.request['data'] = validated + except ValidationError as e: + return error_json_response(400, message=str(e)) + + return await handler(self, *args, **kwargs) + + return wrapper + return decorator diff --git a/app/admin/views.py b/app/admin/views.py new file mode 100644 index 0000000..81c8701 --- /dev/null +++ b/app/admin/views.py @@ -0,0 +1,67 @@ +from http import HTTPStatus + +from aiohttp_apispec import response_schema +from aiohttp_session import new_session + +from app.admin.mixins import AuthRequiredMixin +from app.admin.schemes import ( + AdminResponseSchema, + AdminSchema, + OkResponseSchema, + QuestionResponseSchema, + ThemeResponseSchema, +) +from app.admin.utils import error_json_response, json_response, validate_json +from app.app import View, app + + +class AdminLoginView(View): + @validate_json(AdminSchema) + async def post(self): + data: AdminSchema = self.request["data"] + + admin = await app.accessors.admin_accessor.get_by_email(data.email) + if admin is None or not admin.check_password(data.password): + return error_json_response( + HTTPStatus.FORBIDDEN, + message="Incorrect email or password", + ) + + session = await new_session(request=self.request) + session["admin_email"] = data.email + + return json_response(AdminResponseSchema.model_validate(admin)) + + +class AdminCurrentView(AuthRequiredMixin, View): + async def get(self): + return json_response(AdminResponseSchema.model_validate(self.request.admin)) + + +class ThemesView(AuthRequiredMixin, View): + async def get(self): + themes = await app.accessors.theme_accessor.get_all_themes() + themes_data = [ + ThemeResponseSchema.model_validate(theme).model_dump() for theme in themes + ] + return json_response( + OkResponseSchema(status="ok", data={"themes": themes_data}) + ) + + +class QuestionsView(AuthRequiredMixin, View): + async def get(self): + theme_id = self.request.query.get("theme_id") + if theme_id: + questions = await app.accessors.theme_accessor.get_questions_by_theme( + int(theme_id) + ) + else: + questions = await app.accessors.theme_accessor.get_all_questions() + questions_data = [ + QuestionResponseSchema.model_validate(question).model_dump() + for question in questions + ] + return json_response( + OkResponseSchema(status="ok", data={"questions": questions_data}) + ) \ No newline at end of file diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..cc9fef8 --- /dev/null +++ b/app/app.py @@ -0,0 +1,52 @@ +from aiohttp.web import ( + Application as AiohttpApplication, + Request as AiohttpRequest, + View as AiohttpView, +) +from aiohttp_session import setup as setup_session + +from app.admin.models import AdminModel +from app.admin.routes import setup_routes +from app.bot.manager import TelegramBotManager, setup_bot_api +from app.core.accessor import Accessors, setup_accessors +from app.core.config import Config, setup_config +from app.core.database.database import Database, setup_database +from app.core.session import setup_session + + +class Application(AiohttpApplication): + bot_api: TelegramBotManager + database: Database + config: Config + accessors: Accessors + + +class Request(AiohttpRequest): + admin: AdminModel | None = None + + @property + def app(self) -> Application: + return super().app() + + +class View(AiohttpView): + @property + def request(self) -> Request: + return super().request + + @property + def data(self) -> dict: + return self.request.get("data", {}) + + +app = Application() + + +def setup_app() -> Application: + setup_config(app) + setup_database(app) + setup_bot_api(app) + setup_accessors(app) + setup_session(app, key=app.config.session.key) + setup_routes(app) + return app diff --git a/app/bot/Dockerfile b/app/bot/Dockerfile new file mode 100644 index 0000000..a7fb119 --- /dev/null +++ b/app/bot/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +CMD python -m app.bot.views \ No newline at end of file diff --git a/app/users/views/__init__.py b/app/bot/__init__.py similarity index 100% rename from app/users/views/__init__.py rename to app/bot/__init__.py diff --git a/app/bot/accessor.py b/app/bot/accessor.py new file mode 100644 index 0000000..6c92159 --- /dev/null +++ b/app/bot/accessor.py @@ -0,0 +1,460 @@ +from typing import cast + +from sqlalchemy import exists, func, insert, select, update +from sqlalchemy.orm import selectinload + +from app.bot.models import ( + AnswerStatusEnum, + GameModel, + GameStatusEnum, + QuestionModel, + QuestionToThemeModel, + RoundModel, + RoundToGameModel, + RoundTypeEnum, + TelegramUserModel, + TelegramUserToGameModel, + TelegramUserToRoundModel, + ThemeModel, + ThemeToRoundModel, +) +from app.bot.schemas import Chat, Message, User +from app.core.accessor_base import BaseAccessor + + +class UserAccessor(BaseAccessor): + async def get_or_create(self, tele_user: User) -> TelegramUserModel: + exp = select(TelegramUserModel).where(TelegramUserModel.id == tele_user.id) + if (user := await self.scalar(exp)) is not None: + return cast(TelegramUserModel, user) + + exp1 = ( + insert(TelegramUserModel) + .values(id=tele_user.id, username=tele_user.username) + .returning(TelegramUserModel) + ) + return cast(TelegramUserModel, await self.scalar(exp1)) + + async def get_by_id(self, user_id: int) -> TelegramUserModel: + exp = select(TelegramUserModel).where(TelegramUserModel.id == user_id) + return await self.scalar(exp) + + +class GameAccessor(BaseAccessor): # noqa: PLR0904 + async def complete(self, game: GameModel) -> None: + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.COMPLETED) + ) + await self.execute(exp) + + async def get_active_game(self, chat: Chat) -> GameModel | None: + exp = ( + select(GameModel) + .where(GameModel.chat_id == chat.id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return await self.scalar(exp) + + async def get(self, chat_id: int, master_id: int) -> GameModel | None: + exp = ( + select(GameModel) + .where(GameModel.chat_id == chat_id) + .where(GameModel.master_id == master_id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return await self.scalar(exp) + + async def get_by_id(self, game_id: int) -> GameModel | None: + return await self.scalar(select(GameModel).where(GameModel.id == game_id)) + + async def create(self, chat_id: int, master_id: int) -> GameModel: + if (game := await self.get(chat_id, master_id)) is not None: + return game + + exp = ( + insert(GameModel) + .values(chat_id=chat_id, master_id=master_id, status=GameStatusEnum.LOBBY) + .returning(GameModel) + ) + return cast(GameModel, await self.scalar(exp)) + + async def add_player(self, user: User, game_chat: Chat) -> bool: + await self.app.accessors.user_accessor.get_or_create(user) + exp = ( + select(TelegramUserToGameModel) + .join(GameModel, TelegramUserToGameModel.game_id == GameModel.id) + .where(TelegramUserToGameModel.user_id == user.id) + .where(GameModel.chat_id == game_chat.id) + .where(GameModel.status == GameStatusEnum.LOBBY) + ) + if await self.scalar(exp) is None: + game_id_stmt = ( + select(GameModel.id) + .where(GameModel.chat_id == game_chat.id) + .where(GameModel.status == GameStatusEnum.LOBBY) + ) + game_id = await self.scalar(game_id_stmt) + if game_id is None: + return False + + insert_stmt = insert(TelegramUserToGameModel).values( + user_id=user.id, + game_id=game_id, + ) + await self.execute(insert_stmt) + return True + return False + + async def all_users(self, chat: Chat) -> list[TelegramUserModel]: + exp = ( + select(TelegramUserModel) + .join( + TelegramUserToGameModel, + TelegramUserToGameModel.user_id == TelegramUserModel.id, + ) + .join(GameModel, TelegramUserToGameModel.game_id == GameModel.id) + .where(GameModel.chat_id == chat.id) + .where(GameModel.status != GameStatusEnum.COMPLETED) + ) + return list(await self.scalars(exp)) + + async def next_round(self, chat: Chat) -> bool: + game = await self.get_active_game(chat) + if game is None: + raise RuntimeError("Game not found") + + if game.status == GameStatusEnum.ROUND_1: + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.COMPLETED) + ) + await self.execute(exp) + return False # Next? + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(status=GameStatusEnum.ROUND_1) + ) + await self.execute(exp) + + exp1 = ( + insert(RoundModel).values(type=RoundTypeEnum.ROUND_1).returning(RoundModel) + ) + round_ = await self.scalar(exp1) + if round_ is None: + raise RuntimeError("Round not found") + + exp2 = ( + insert(RoundToGameModel) + .values(game_id=game.id, round_id=round_.id) + .returning(RoundToGameModel) + ) + await self.execute(exp2) + + exp3 = ( + select(ThemeModel) + .options(selectinload(ThemeModel.questions)) + .order_by(func.random()) + .limit(3) + ) + themes = await self.scalars(exp3) + + for theme in themes: + exp4 = insert(ThemeToRoundModel).values( + theme_id=theme.id, round_id=round_.id + ) + await self.execute(exp4) + + for question in theme.questions: + exp5 = insert(QuestionToThemeModel).values( + question_id=question.id, + theme_id=theme.id, + round_id=round_.id, + ) + await self.execute(exp5) + return True # Next? + + async def get_current_round(self, chat: Chat) -> RoundModel: + game = await self.get_active_game(chat) + exp = ( + select(RoundModel) + .join(RoundToGameModel, RoundModel.id == RoundToGameModel.round_id) + .where( + RoundModel.type == game.status, + RoundToGameModel.game_id == game.id, + ) + ) + return await self.scalar(exp) + + async def all_questions(self, chat: Chat) -> list[list[QuestionToThemeModel]]: + round_ = await self.get_current_round(chat) + if round_ is None: + return [] + + stmt = ( + select(QuestionToThemeModel) + .where(QuestionToThemeModel.round_id == round_.id) + .options( + selectinload(QuestionToThemeModel.question), + selectinload(QuestionToThemeModel.theme), + ) + .order_by(QuestionToThemeModel.theme_id) + ) + + records = await self.scalars(stmt) + + grouped: dict[int, list[QuestionToThemeModel]] = {} + for item in records: + grouped.setdefault(item.theme_id, []).append(item) + + return list(grouped.values()) + + async def set_choice_user( + self, + chat: Chat, + choice: TelegramUserModel, + ) -> TelegramUserModel: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(choice_user_id=choice.id) + ) + await self.execute(exp) + return choice + + async def set_active_user_null(self, chat: Chat) -> None: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel).where(GameModel.id == game.id).values(active_user_id=None) + ) + await self.execute(exp) + + async def set_active_user( + self, + chat: Chat, + active: User, + user_id: int, + question_id: int, + round_id: int, + ) -> None: + game = await self.get_active_game(chat) + + exp = ( + update(GameModel) + .where(GameModel.id == game.id) + .values(active_user_id=active.id) + ) + await self.execute(exp) + + exp1 = ( + update(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + .values(state=AnswerStatusEnum.WAIT_ANSWERED) + ) + await self.execute(exp1) + + async def get_question_by_message( + self, msg_or_call: Message + ) -> QuestionModel: + round_ = await self.get_current_round(msg_or_call.chat) + + exp1 = ( + select(TelegramUserToRoundModel) + .where(TelegramUserToRoundModel.user_id == msg_or_call.from_.id) + .where(TelegramUserToRoundModel.round_id == round_.id) + .where(TelegramUserToRoundModel.state == AnswerStatusEnum.WAIT_ANSWERED) + ) + res = await self.scalar(exp1) + question_id, round_id = res.question_id, res.round_id + + exp2 = select(QuestionModel).where(QuestionModel.id == question_id) + theme_id = (await self.scalar(exp2)).theme_id + + exp = select(QuestionToThemeModel).where( + QuestionToThemeModel.round_id == round_id, + QuestionToThemeModel.theme_id == theme_id, + QuestionToThemeModel.question_id == question_id, + ) + question_to_theme = await self.scalar(exp) + + exp1 = ( + select(QuestionModel) + .options(selectinload(QuestionModel.theme)) + .where(QuestionModel.id == question_to_theme.question_id) + ) + + return await self.scalar(exp1) + + async def get_active_user(self, chat: Chat) -> TelegramUserModel | None: + game = await self.get_active_game(chat) + if game is None: + return None + + exp = select(TelegramUserModel).where( + TelegramUserModel.id == game.active_user_id + ) + return await self.scalar(exp) + + async def set_user_answered( + self, + user_id: int, + question_id: int, + round_id: int, + ) -> None: + exp = ( + update(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + .values(state=AnswerStatusEnum.ANSWERED) + ) + await self.execute(exp) + + async def is_answered(self, user_id: int, question_id: int, round_id: int) -> bool: + exp = select(TelegramUserToRoundModel).where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.ANSWERED, + ) + return await self.scalar(exp) is not None + + async def get_question_by_user_round( + self, + user_id: int, + question_id: int, + round_: RoundModel, + ) -> QuestionModel: + exp = ( + select(TelegramUserToRoundModel) + .options(selectinload(TelegramUserToRoundModel.question)) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.round_id == round_.id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.ANSWERED, + ) + ) + res = await self.scalar(exp) + return res.question + + async def add_score(self, user_id: int, game_id: int, score: int) -> None: + exp = ( + update(TelegramUserToGameModel) + .where( + TelegramUserToGameModel.user_id == user_id, + TelegramUserToGameModel.game_id == game_id, + ) + .values( + score=TelegramUserToGameModel.score + score, + ) + ) + await self.execute(exp) + + async def set_question_answered( + self, theme_id: int, question_id: int, round_id: int + ) -> None: + exp = ( + update(QuestionToThemeModel) + .where( + QuestionToThemeModel.theme_id == theme_id, + QuestionToThemeModel.question_id == question_id, + QuestionToThemeModel.round_id == round_id, + ) + .values(status=AnswerStatusEnum.ANSWERED) + ) + await self.execute(exp) + + async def has_questions(self, round_: RoundModel) -> bool: + exp = select( + exists(QuestionToThemeModel).where( + QuestionToThemeModel.round_id == round_.id, + QuestionToThemeModel.status == AnswerStatusEnum.NOT_ANSWERED, + ), + ) + return await self.scalar(exp) + + async def all_profiles(self, game_id: int) -> list[TelegramUserToGameModel]: + exp = ( + select(TelegramUserToGameModel) + .options(selectinload(TelegramUserToGameModel.user)) + .where(TelegramUserToGameModel.game_id == game_id) + ) + return list(await self.scalars(exp)) + + async def summarize(self, profile: TelegramUserToGameModel, is_win: bool) -> None: + exp = ( + update(TelegramUserModel) + .where(TelegramUserModel.id == profile.user_id) + .values( + score=TelegramUserModel.score + profile.score, + win_count=( + TelegramUserModel.win_count + 1 + if is_win + else TelegramUserModel.win_count + ), + loss_count=( + TelegramUserModel.loss_count + 1 + if not is_win + else TelegramUserModel.loss_count + ), + ) + ) + await self.execute(exp) + + async def generate_users_answer_status( + self, + chat: Chat, + question_id: int, + round_id: int, + ) -> None: + users = await self.all_users(chat) + + for user in users: + exp = insert(TelegramUserToRoundModel).values( + user_id=user.id, + question_id=question_id, + round_id=round_id, + ) + await self.execute(exp) + + async def has_user_not_answered(self, round_id: int, question_id: int) -> bool: + exp = select( + exists(TelegramUserToRoundModel).where( + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + TelegramUserToRoundModel.state == AnswerStatusEnum.NOT_ANSWERED, + ) + ) + return await self.scalar(exp) + + async def has_answer(self, user_id: int, round_id: int, question_id: int) -> bool: + exp = ( + select(TelegramUserToRoundModel) + .where( + TelegramUserToRoundModel.user_id == user_id, + TelegramUserToRoundModel.question_id == question_id, + TelegramUserToRoundModel.round_id == round_id, + ) + ) + return (await self.scalar(exp)).state == AnswerStatusEnum.ANSWERED + + async def get_question_by_id(self, question_id: int) -> QuestionModel: + exp = ( + select(QuestionModel) + .where(QuestionModel.id == question_id) + ) + return await self.scalar(exp) diff --git a/app/bot/manager.py b/app/bot/manager.py new file mode 100644 index 0000000..f9f2f39 --- /dev/null +++ b/app/bot/manager.py @@ -0,0 +1,194 @@ +import asyncio +import json +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Literal, cast + +import aiohttp +from aio_pika.abc import AbstractIncomingMessage + +from app.bot.schemas import CallbackQuery, Chat, Message, TelegramUpdate +from app.core.manager import RabbitMQManager + +if TYPE_CHECKING: + from app.app import Application + + +class TelegramBotManager: + def __init__(self, app: "Application"): + self.app = app + self._token = app.config.bot.token + self._base_url = "https://api.telegram.org/bot" + self._session: aiohttp.ClientSession | None = None + self._handlers: list[ + tuple[list[str] | None, Callable[[Message], Awaitable[None]]] + ] = [] + self._callback_handlers: list[ + tuple[str | None, Callable[[CallbackQuery], Awaitable[None]]] + ] = [] + + def build_method_url(self, method_name: str) -> str: + return f"{self._base_url}{self._token}/{method_name}" + + async def _mainloop(self) -> None: + await self.connect() + rabbit = RabbitMQManager( + amqp_url=self.app.config.rabbitmq.url, + queue_name=self.app.config.rabbitmq.input_queue, + ) + await rabbit.connect() + await rabbit.consume(self.get_update) + + await asyncio.Event().wait() + + def mainloop(self) -> None: + asyncio.run(self._mainloop()) + + async def connect(self) -> None: + self._session = aiohttp.ClientSession() + + async def close(self) -> None: + if self._session: + await self._session.close() + + async def _post(self, method: str, json: dict[str, Any]) -> dict[Any, Any]: + if self._session is None: + raise RuntimeError("TelegramBotManager is not connected") + + async with self._session.post(self.build_method_url(method), json=json) as resp: + response_data: dict[str, Any] = await resp.json() + if not response_data.get("ok"): + raise RuntimeError(f"Telegram API error: {response_data}") + return cast(dict[Any, Any], response_data["result"]) + + async def get_update(self, msg: AbstractIncomingMessage) -> None: + async with msg.process(): + data = json.loads(msg.body) + update = TelegramUpdate(**data) + + if update.message and update.message.text: + text = update.message.text.strip() + is_command = text.startswith("/") + + for commands, handler in self._handlers: + if commands is None and not is_command: + await handler(update.message) + elif ( + commands is not None + and is_command + and any( + text.split()[0] == f"/{command}" for command in commands + ) + ): + await handler(update.message) + return + + elif update.callback_query and update.callback_query.data: + data = update.callback_query.data + for expected_data, handler in self._callback_handlers: + if expected_data is None or data == expected_data: + await handler(update.callback_query) + return + + def connect_handler( + self, commands: list[str] | None = None + ) -> Callable[ + [Callable[[Message], Awaitable[None]]], + Callable[[Message], Awaitable[None]], + ]: + def decorator( + func: Callable[[Message], Awaitable[None]], + ) -> Callable[[Message], Awaitable[None]]: + self._handlers.append((commands, func)) + return func + + return decorator + + def connect_callback_handler( + self, + data_value: str | None = None, + ) -> Callable[ + [Callable[[CallbackQuery], Awaitable[None]]], + Callable[[CallbackQuery], Awaitable[None]], + ]: + def decorator( + func: Callable[[CallbackQuery], Awaitable[None]], + ) -> Callable[[CallbackQuery], Awaitable[None]]: + self._callback_handlers.append((data_value, func)) + return func + + return decorator + + async def send_message( + self, + chat: Chat, + text: Any, + keyboard: list[list[tuple[str, str]]] | None = None, + parse_mode: Literal["MarkdownV2", "HTML", "Markdown"] | None = None, + reply_to_message_id: int | None = None, + ) -> dict[Any, Any]: + json_data = { + "chat_id": chat.id, + "text": str(text), + } + + if parse_mode is not None: + json_data["parse_mode"] = parse_mode + + if keyboard is not None: + inline_keyboard = [ + [{"text": label, "callback_data": data} for label, data in row] + for row in keyboard + ] + json_data["reply_markup"] = {"inline_keyboard": inline_keyboard} + + if reply_to_message_id is not None: + json_data["reply_parameters"] = {"message_id": reply_to_message_id} + + return await self._post("sendMessage", json=json_data) + + async def answer_callback_query( + self, + callback_query: CallbackQuery, + text: str = "", + show_alert: bool = False, + ) -> dict[Any, Any]: + payload = { + "callback_query_id": callback_query.id, + "text": text, + "show_alert": show_alert, + } + + if not text: + payload.pop("text") + + return await self._post("answerCallbackQuery", json=payload) + + async def edit_message_text( + self, + chat_id: int, + message_id: int, + text: str, + keyboard: list[list[tuple[str, str]]] | None = None, + parse_mode: Literal["MarkdownV2", "HTML", "Markdown"] | None = None, + ) -> dict[Any, Any]: + payload: dict[str, Any] = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + + if keyboard: + inline_keyboard = [ + [{"text": label, "callback_data": data} for label, data in row] + for row in keyboard + ] + payload["reply_markup"] = {"inline_keyboard": inline_keyboard} + + if parse_mode is not None: + payload["parse_mode"] = parse_mode + + return await self._post("editMessageText", json=payload) + + +def setup_bot_api(app: "Application") -> None: + app.bot_api = TelegramBotManager(app) diff --git a/app/bot/models.py b/app/bot/models.py new file mode 100644 index 0000000..423f5c4 --- /dev/null +++ b/app/bot/models.py @@ -0,0 +1,189 @@ +from datetime import datetime, timedelta +from enum import StrEnum + +from sqlalchemy import ( + TIMESTAMP, + BigInteger, + CheckConstraint, + Enum as PgEnum, + ForeignKey, + Interval, + PrimaryKeyConstraint, + SmallInteger, + String, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database.mixins import IDMixin +from app.core.database.sqlalchemy_base import BaseModel + + +class GameStatusEnum(StrEnum): + LOBBY = "lobby" + ROUND_1 = "round_1" + ROUND_2 = "round_2" + ROUND_3 = "round_3" + COMPLETED = "completed" + + +class RoundTypeEnum(StrEnum): + ROUND_1 = "round_1" + ROUND_2 = "round_2" + ROUND_3 = "round_3" + + +class AnswerStatusEnum(StrEnum): + NOT_ANSWERED = "not_answered" + ANSWERED = "answered" + WAIT_ANSWERED = "wait_answered" + + +class TelegramUserModel(BaseModel): + __tablename__ = "telegram_user" + + __table_args__ = ( + CheckConstraint("win_count >= 0", name="win_count_non_negative"), + CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False) + username: Mapped[str] = mapped_column(String(64)) + score: Mapped[int] = mapped_column(default=0) + win_count: Mapped[int] = mapped_column(default=0) + loss_count: Mapped[int] = mapped_column(default=0) + + +class GameModel(IDMixin, BaseModel): + __tablename__ = "game" + + chat_id: Mapped[int] = mapped_column(BigInteger) + status: Mapped[GameStatusEnum] = mapped_column( + PgEnum(GameStatusEnum), + default=GameStatusEnum.LOBBY, + ) + master_id: Mapped[int] = mapped_column(ForeignKey("telegram_user.id")) + active_user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + nullable=True, + ) + choice_user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + nullable=True, + ) + + +class TelegramUserToGameModel(BaseModel): + __tablename__ = "telegram_user_to_game" + + user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + primary_key=True, + ) + game_id: Mapped[int] = mapped_column(ForeignKey("game.id"), primary_key=True) + score: Mapped[int] = mapped_column(default=0) + + user: Mapped[TelegramUserModel] = relationship() + + +class RoundModel(IDMixin, BaseModel): + __tablename__ = "round" + + type: Mapped[RoundTypeEnum] = mapped_column(PgEnum(RoundTypeEnum)) + + @property + def base_score(self): + data = { + RoundTypeEnum.ROUND_1: 100, + RoundTypeEnum.ROUND_2: 200, + RoundTypeEnum.ROUND_3: 300, + } + return data[self.type] + + +class ThemeModel(IDMixin, BaseModel): + __tablename__ = "theme" + + title: Mapped[str] = mapped_column(String(255)) + questions: Mapped[list["QuestionModel"]] = relationship( + back_populates="theme", + cascade="all, delete-orphan", + ) + + +class QuestionModel(IDMixin, BaseModel): + __tablename__ = "question" + + text: Mapped[str] = mapped_column(String(255)) + answer: Mapped[str] = mapped_column(String(255)) + hard_level: Mapped[int] = mapped_column(SmallInteger) + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id")) + + theme: Mapped[ThemeModel] = relationship( + back_populates="questions", + ) + + +class QuestionToThemeModel(BaseModel): + __tablename__ = "question_to_theme" + __table_args__ = ( + PrimaryKeyConstraint( + "round_id", + "theme_id", + "question_id", + name="pk_question_to_theme", + ), + ) + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id")) + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id")) + question_id: Mapped[int] = mapped_column(ForeignKey("question.id")) + + status: Mapped[AnswerStatusEnum] = mapped_column( + PgEnum(AnswerStatusEnum), + default=AnswerStatusEnum.NOT_ANSWERED, + ) + question: Mapped[QuestionModel] = relationship() + theme: Mapped[ThemeModel] = relationship() + + +class RoundToGameModel(BaseModel): + __tablename__ = "round_to_game" + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + game_id: Mapped[int] = mapped_column(ForeignKey("game.id"), primary_key=True) + + +class ThemeToRoundModel(BaseModel): + __tablename__ = "theme_to_round" + + theme_id: Mapped[int] = mapped_column(ForeignKey("theme.id"), primary_key=True) + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + + +class TelegramUserToRoundModel(BaseModel): + __tablename__ = "telegram_user_to_round" + + user_id: Mapped[int] = mapped_column( + ForeignKey("telegram_user.id"), + primary_key=True, + ) + question_id: Mapped[int] = mapped_column( + ForeignKey("question.id"), + primary_key=True, + ) + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + state: Mapped[AnswerStatusEnum] = mapped_column( + PgEnum(AnswerStatusEnum), + default=AnswerStatusEnum.NOT_ANSWERED, + ) + + question: Mapped[QuestionModel] = relationship() + + +class TimersModel(BaseModel): + __tablename__ = "timers" + + round_id: Mapped[int] = mapped_column(ForeignKey("round.id"), primary_key=True) + create_at: Mapped[datetime] = mapped_column(TIMESTAMP, default=datetime.utcnow) + question_id: Mapped[int] = mapped_column(ForeignKey("question.id")) + duration: Mapped[timedelta] = mapped_column(Interval) diff --git a/app/bot/schemas.py b/app/bot/schemas.py new file mode 100644 index 0000000..b90e341 --- /dev/null +++ b/app/bot/schemas.py @@ -0,0 +1,33 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class User(BaseModel): + id: int + username: str + + +class Chat(BaseModel): + id: int + type: Literal["private", "group", "supergroup", "channel"] = "group" + + +class Message(BaseModel): + message_id: int + text: str | None = None + chat: Chat + from_: User = Field(alias="from") + + +class CallbackQuery(BaseModel): + id: str + from_: User = Field(alias="from") + data: str + message: Message + + +class TelegramUpdate(BaseModel): + update_id: int + message: Message | None = None + callback_query: CallbackQuery | None = None diff --git a/app/bot/utils.py b/app/bot/utils.py new file mode 100644 index 0000000..7c5960a --- /dev/null +++ b/app/bot/utils.py @@ -0,0 +1,6 @@ +import re + + +def escape_markdown_v2(text: str) -> str: + escape_chars = r"_*[]()~`>#+-=|{}.!\\" + return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text) \ No newline at end of file diff --git a/app/bot/views.py b/app/bot/views.py new file mode 100644 index 0000000..bc2a13f --- /dev/null +++ b/app/bot/views.py @@ -0,0 +1,440 @@ +from random import choice + +from app.app import app, setup_app +from app.bot.models import AnswerStatusEnum, TelegramUserModel +from app.bot.schemas import CallbackQuery, Chat, Message +from app.bot.utils import escape_markdown_v2 + +setup_app() +bot = app.bot_api +app.database.connect() + + +async def generate_question_keyboard( + call_or_chat: CallbackQuery | Chat, + user: TelegramUserModel, +) -> None: + if isinstance(call_or_chat, CallbackQuery): + groups = await app.accessors.game_accessor.all_questions( + call_or_chat.message.chat, + ) + if not groups: + await bot.send_message( + call_or_chat.message.chat, + "Нет вопросов в текущем раунде", + ) + return + else: + groups = await app.accessors.game_accessor.all_questions(call_or_chat) + if not groups: + await bot.send_message(call_or_chat, "Нет вопросов в текущем раунде") + return + + lines = [] + keyboard = [] + + for idx, group in enumerate(groups, start=1): + theme = group[0].theme + lines.append(f"{idx}. {theme.title}") + + row = [] + for qtt in sorted(group, key=lambda x: x.question.hard_level): + price = qtt.question.hard_level * 100 + + if qtt.status == AnswerStatusEnum.ANSWERED: + row.append(("-X-X-", "answered")) + else: + row.append( + (f"{idx}) {price}", f"btn_choice:{qtt.round_id}:{qtt.question_id}"), + ) + keyboard.append(row) + + if isinstance(call_or_chat, CallbackQuery): + await bot.edit_message_text( + call_or_chat.message.chat.id, + call_or_chat.message.message_id, + f"Итак, начнём!\n\nВыбирает тему @{user.username}:\n" + "\n".join(lines), + keyboard=keyboard, + ) + else: + await bot.send_message( + call_or_chat, + f"А мы продолжаем!\n\nВыбирает тему @{user.username}:\n" + "\n".join(lines), + keyboard=keyboard, + ) + + +async def summarize_the_results(chat: Chat, game_id: int) -> None: + profiles = await app.accessors.game_accessor.all_profiles(game_id) + + profiles = sorted(profiles, key=lambda p: p.score, reverse=True) + lines = [] + for i, profile in enumerate(profiles): + lines.append(f"@{profile.user.username} — {profile.score}") + await app.accessors.game_accessor.summarize(profile, i == 0) + + await bot.send_message( + chat, + "Что ж, наша игра подходит к концу, и наш общий счёт:\n\n" + "\n".join(lines), + ) + + +@bot.connect_handler(commands=["start", "приветик"]) +async def start(message: Message) -> None: + if message.chat.type == "private": + await bot.send_message( + message.chat, + "Привет, это бот `Своя игра`, добавь меня в игру и мы сыграем вместе", + ) + return + + if await app.accessors.game_accessor.get_active_game(message.chat) is not None: + await bot.send_message(message.chat, "В этом чате уже есть активная игра") + return + + master = await app.accessors.user_accessor.get_or_create(message.from_) + await app.accessors.game_accessor.create(message.chat.id, master.id) + await bot.send_message( + message.chat, + "Новая игра\n\nНет\nСвоя игра!", + [ + [("Начать игру", "start_game")], + [("Присоединиться", "connect_to_game")], + ], + ) + + +@bot.connect_handler(commands=["stop", "пакетик"]) +async def stop(message: Message) -> None: + game = await app.accessors.game_accessor.get_active_game(message.chat) + if game is None: + return + + await app.accessors.game_accessor.complete(game) + + if game.master_id == message.from_.id: + await bot.send_message(message.chat, "Игра завершена досрочно") + + +@bot.connect_callback_handler("start_game") +async def start_game_handler(call: CallbackQuery) -> None: + if ( + await app.accessors.game_accessor.get( + call.message.chat.id, + call.from_.id, + ) + is None + ): + await bot.answer_callback_query( + call, + "Сори, только для ведущего!", + show_alert=True, + ) + return + + users = await app.accessors.game_accessor.all_users(call.message.chat) + if len(users) == 0: + await bot.answer_callback_query( + call, + "Нужен хотя бы один игрок", + show_alert=True, + ) + return + + await app.accessors.game_accessor.next_round(call.message.chat) + user = await app.accessors.game_accessor.set_choice_user( + call.message.chat, + choice(users), + ) + + await generate_question_keyboard(call, user) + + +@bot.connect_callback_handler("connect_to_game") +async def connect_handler(call: CallbackQuery) -> None: + if ( + await app.accessors.game_accessor.get( + call.message.chat.id, + call.from_.id, + ) + is not None + ): + await bot.answer_callback_query(call, "Ты же ведущий...", show_alert=True) + return + + if not await app.accessors.game_accessor.add_player(call.from_, call.message.chat): + await bot.answer_callback_query(call, "Ты уже участвуешь!", show_alert=True) + return + + users = await app.accessors.game_accessor.all_users(call.message.chat) + await bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + text="Новая игра\n" + "\n" + "Нет\n" + "Своя игра!\n" + "\n" + "Наши участники:" + "\n" + "\n".join(["- @" + user.username for user in users]), + keyboard=[ + [("Начать игру", "start_game")], + [("Присоединиться", "connect_to_game")], + ], + ) + + +@bot.connect_callback_handler() +async def choice_button(call: CallbackQuery) -> None: + if call.data.startswith("answered"): + await bot.answer_callback_query(call) + return + + if call.data.startswith("btn_choice"): + game = await app.accessors.game_accessor.get_active_game(call.message.chat) + round_id, question_id = map(int, call.data.split(":")[1:]) + + if game.choice_user_id != call.from_.id: + await bot.answer_callback_query( + call, + "Не ты выбираешь тему!", + show_alert=True, + ) + return + + await app.accessors.game_accessor.generate_users_answer_status( + call.message.chat, + question_id, + round_id, + ) + qst = await app.accessors.game_accessor.get_question_by_id(question_id) + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + f"Окей, игрок @{call.from_.username}, выбрал вопрос:\n\n{qst.text}", + keyboard=[ + [("Ответить", f"btn_answer:{round_id}:{question_id}")], + ], + ) + elif call.data.startswith("btn_answer"): + round_id, question_id = map(int, call.data.split(":")[1:]) + game = await app.accessors.game_accessor.get_active_game(call.message.chat) + + if game.master_id == call.from_.id: + await bot.answer_callback_query( + call, + "Ты ведущий! Помни об этом", + show_alert=True, + ) + + if await app.accessors.game_accessor.has_answer( + call.from_.id, + round_id, + question_id, + ): + await bot.answer_callback_query(call, "Ты уже ответил!", show_alert=True) + return + + await app.accessors.game_accessor.set_active_user( + call.message.chat, + call.from_, + call.from_.id, + question_id, + round_id, + ) + qst = await app.accessors.game_accessor.get_question_by_id(question_id) + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + f"Отвечает @{call.from_.username}\n" + "\n" + f"{qst.text}\n" + "\n" + "Следующие ваше сообщение будет считаться ответом", + ) + else: + result, user_id, game_id, reply, question_id = call.data.split(":") + user_id, game_id, reply, question_id = list( + map(int, [user_id, game_id, reply, question_id]) + ) + game = await app.accessors.game_accessor.get_by_id(game_id) + round_ = await app.accessors.game_accessor.get_current_round( + Chat(id=game.chat_id), + ) + user = await app.accessors.user_accessor.get_by_id(user_id) + qst = await app.accessors.game_accessor.get_question_by_user_round( + user_id, + question_id, + round_, + ) + score = qst.hard_level * round_.base_score + + await bot.edit_message_text( + call.message.chat.id, + call.message.message_id, + call.message.text, + ) + + if result == "correct": + await app.accessors.game_accessor.add_score(user_id, game_id, score) + await bot.send_message( + Chat(id=game.chat_id), + f"Иии.. ваш ответ верен!\n+ {score} очков", + reply_to_message_id=reply, + ) + await app.accessors.game_accessor.set_choice_user( + Chat(id=game.chat_id), user + ) + await app.accessors.game_accessor.set_active_user_null( + Chat(id=game.chat_id), + ) + + if await app.accessors.game_accessor.has_questions(round_): + await generate_question_keyboard(Chat(id=game.chat_id), user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(Chat(id=game.chat_id), game_id) + return + else: + await app.accessors.game_accessor.add_score(user_id, game_id, -score) + await bot.send_message( + Chat(id=game.chat_id), + f"Иии.. увы, ваш ответ неверен!\n- {score} очков", + reply_to_message_id=reply, + ) + await app.accessors.game_accessor.set_active_user_null( + Chat(id=game.chat_id), + ) + if await app.accessors.game_accessor.has_user_not_answered( + round_.id, + question_id, + ): + await bot.send_message( + Chat(id=game.chat_id), + f"Может кто-то другой ответит на вопрос?:\n\n{qst.text}", + keyboard=[ + [("Ответить", f"btn_answer:{round_.id}:{question_id}")], + ], + ) + return + + await bot.send_message( + Chat(id=game.chat_id), + "Пу пу пу, никто не ответил, правильно\n" + "\n" + "А правильный ответ был\n" + f"{qst.answer}", + ) + + if await app.accessors.game_accessor.has_questions(round_): + await generate_question_keyboard(Chat(id=game.chat_id), user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(Chat(id=game.chat_id), game_id) + return + + await bot.answer_callback_query(call) + + +@bot.connect_handler() +async def start_game(message: Message) -> None: + game = await app.accessors.game_accessor.get_active_game(message.chat) + if game is None: + return + + active_user = await app.accessors.game_accessor.get_active_user(message.chat) + if active_user is None: + return + + if message.from_.id == active_user.id: + question = await app.accessors.game_accessor.get_question_by_message(message) + round_ = await app.accessors.game_accessor.get_current_round(message.chat) + + if await app.accessors.game_accessor.is_answered( + message.from_.id, + question.id, + round_.id, + ): + return + + await app.accessors.game_accessor.set_user_answered( + message.from_.id, + question.id, + round_.id, + ) + await app.accessors.game_accessor.set_question_answered( + question.theme.id, + question.id, + round_.id, + ) + + try: + await bot.send_message( + Chat(id=game.master_id), + f"Игрок @{escape_markdown_v2(message.from_.username)} выбрал вопрос:\n" + "```\n" + f"{question.text}" + "```\n" + "\n" + "Ответ:\n" + "```\n" + f"{question.answer}" + "```\n" + "\n" + "Ответ игрока:\n" + "```\n" + f"{message.text}" + f"```", + parse_mode="MarkdownV2", + keyboard=[ + [ + ( + "Верно", + f"correct:{message.from_.id}:{game.id}:{message.message_id}:{question.id}", + ), + ( + "Неверно", + f"wrong:{message.from_.id}:{game.id}:{message.message_id}:{question.id}", + ), + ], + ], + ) + except RuntimeError as err: + if "Forbidden: bot can't initiate conversation with a user" in str(err): + await bot.send_message( + message.chat, + "Я не могу написать ведущему первым, " + "чтобы отправить на проверку ответ\n" + "\n" + "Ответ не засчитан", + ) + + await app.accessors.game_accessor.set_active_user_null(message.chat) + + if await app.accessors.game_accessor.has_questions(round_): + user = await app.accessors.user_accessor.get_by_id(message.from_.id) + await generate_question_keyboard(message.chat, user) + else: + next_ = await app.accessors.game_accessor.next_round( + Chat(id=game.chat_id), + ) + if next_ is False: + await summarize_the_results(message.chat, game.id) + + return + + await bot.send_message( + message.chat, + "Ваш ответ мы отправили ведущему на проверку", + reply_to_message_id=message.message_id, + ) + + +if __name__ == "__main__": + bot.mainloop() diff --git a/app/web/__init__.py b/app/core/__init__.py similarity index 100% rename from app/web/__init__.py rename to app/core/__init__.py diff --git a/app/core/accessor.py b/app/core/accessor.py new file mode 100644 index 0000000..d83d1f5 --- /dev/null +++ b/app/core/accessor.py @@ -0,0 +1,45 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from app.admin.accessor import AdminAccessor, ThemeAccessor +from app.bot.accessor import GameAccessor, UserAccessor +from app.core.accessor_base import BaseAccessor + +if TYPE_CHECKING: + from app.app import Application + + +@asynccontextmanager +async def transaction(db: BaseAccessor) -> AsyncGenerator[AsyncSession, None]: + session = db.get_current_session() + + if session: + async with session.begin_nested(): + yield session + else: + async with db.session() as session, session.begin(): + yield session + + +class Accessors: + base_accessor: BaseAccessor + user_accessor: UserAccessor + game_accessor: GameAccessor + admin_accessor: AdminAccessor + theme_accessor: ThemeAccessor + + def __init__(self, app: "Application"): + self.base_accessor = BaseAccessor(app) + self.user_accessor = UserAccessor(app) + self.game_accessor = GameAccessor(app) + self.admin_accessor = AdminAccessor(app) + self.theme_accessor = ThemeAccessor(app) + + +def setup_accessors(app: "Application") -> None: + app.accessors = Accessors(app) diff --git a/app/core/accessor_base.py b/app/core/accessor_base.py new file mode 100644 index 0000000..35ad13b --- /dev/null +++ b/app/core/accessor_base.py @@ -0,0 +1,82 @@ +from asyncio import current_task +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Row +from sqlalchemy.engine import CursorResult, Result, ScalarResult +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_scoped_session, + async_sessionmaker, +) +from sqlalchemy.sql.base import Executable + +if TYPE_CHECKING: + from app.app import Application + + +class BaseAccessor: + def __init__(self, app: "Application"): + self.app = app + + self._current_session: ContextVar[AsyncSession | None] = ContextVar( + "current_session", + default=None, + ) + + @property + def session_maker(self) -> async_sessionmaker[AsyncSession]: + if self.app.database.session is None: + raise RuntimeError("DatabaseAccessor is not connected") + return self.app.database.session + + @asynccontextmanager + async def session(self) -> AsyncGenerator[AsyncSession, None]: + scoped_session = async_scoped_session( + session_factory=self.session_maker, + scopefunc=current_task, + ) + + async with scoped_session() as session: + token = self._current_session.set(session) + + yield session + await session.commit() + + self._current_session.reset(token) + await scoped_session.remove() + + def get_current_session(self) -> AsyncSession | None: + return self._current_session.get() + + async def execute( + self, + statement: Executable, + ) -> CursorResult[Any] | Result[Any]: + session = self.get_current_session() + + if session: + return await session.execute(statement) + + async with self.session() as session: + return await session.execute(statement) + + async def scalar(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).scalar() + + async def scalars(self, statement: Executable) -> ScalarResult[Any]: + return (await self.execute(statement)).scalars() + + async def one(self, statement: Executable) -> Any: + return (await self.execute(statement)).one() + + async def one_or_none(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).one_or_none() + + async def first(self, statement: Executable) -> Any | None: + return (await self.execute(statement)).first() + + async def all(self, statement: Executable) -> Sequence[Row[Any]]: + return (await self.execute(statement)).all() diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..7b86cfc --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,72 @@ +import typing +from functools import cached_property + +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict +from sqlalchemy.engine.url import URL + +if typing.TYPE_CHECKING: + from app.app import Application + + +class SessionConfig(BaseModel): + key: str + + +class BotConfig(BaseModel): + token: str = "..." + + +class AdminConfig(BaseSettings): + login: str = "admin" + password: str = "admin" + + +class DatabaseConfig(BaseModel): + host: str = "localhost" + port: int = 5432 + user: str = "postgres" + password: str = "postgres" + database: str = "project" + + @cached_property + def url(self) -> URL: + return URL.create( + drivername="postgresql+asyncpg", + username=self.user, + password=self.password, + host=self.host, + port=self.port, + database=self.database, + ) + + +class RabbitmqConfig(BaseModel): + host: str = "localhost" + port: int = 5672 + user: str = "guest" + password: str = "guest" + input_queue: str = "input_queue" + output_queue: str = "output_queue" + + @cached_property + def url(self) -> str: + return f"amqp://{self.user}:{self.password}@{self.host}:{self.port}" + + +class Config(BaseSettings): + session: SessionConfig + admin: AdminConfig + bot: BotConfig + database: DatabaseConfig + rabbitmq: RabbitmqConfig + + model_config = SettingsConfigDict( + # Если main / если poller / если миграции + env_file=(".env", "../../.env", "../../../../.env"), + env_nested_delimiter="__", + ) + + +def setup_config(app: "Application") -> None: + app.config = Config() diff --git a/tests/__init__.py b/app/core/database/__init__.py similarity index 100% rename from tests/__init__.py rename to app/core/database/__init__.py diff --git a/app/core/database/database.py b/app/core/database/database.py new file mode 100644 index 0000000..15cd1a9 --- /dev/null +++ b/app/core/database/database.py @@ -0,0 +1,55 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import URL +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.core.database.sqlalchemy_base import BaseModel + +if TYPE_CHECKING: + from app.app import Application + + +class Database: + def __init__(self, app: "Application") -> None: + self.app = app + self.engine: AsyncEngine | None = None + self._db: type[DeclarativeBase] = BaseModel + self.session: async_sessionmaker[AsyncSession] | None = None + + def connect(self) -> None: + if self.app.config is None or self.app.config.database is None: + raise ValueError("Configuration or Database is not properly initialized.") + + self.engine = create_async_engine( + URL.create( + drivername="postgresql+asyncpg", + username=self.app.config.database.user, + password=self.app.config.database.password, + host=self.app.config.database.host, + port=self.app.config.database.port, + database=self.app.config.database.database, + ), + echo=True, + future=True, + ) + self.session = async_sessionmaker( + self.engine, + autoflush=False, + autocommit=False, + expire_on_commit=False, + ) + + async def disconnect(self) -> None: + if self.engine is None: + raise ValueError("Engine is not properly initialized.") + await self.engine.dispose() + + +def setup_database(app: "Application") -> None: + app.database = Database(app) diff --git a/app/core/database/migrations/__init__.py b/app/core/database/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/database/migrations/env.py b/app/core/database/migrations/env.py new file mode 100644 index 0000000..d9ecf29 --- /dev/null +++ b/app/core/database/migrations/env.py @@ -0,0 +1,75 @@ +import asyncio +import importlib +import pkgutil +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +import app +from app.app import app as application, setup_app +from app.core.database.sqlalchemy_base import BaseModel + +setup_app() +config = context.config + +for module_info in pkgutil.walk_packages(app.__path__, prefix=app.__name__ + "."): + importlib.import_module(module_info.name) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +if application.config is None or application.config.database is None: + raise ValueError("No configuration file provided") + +config.set_main_option( + "sqlalchemy.url", + application.config.database.url.render_as_string(hide_password=False), +) + +target_metadata = BaseModel.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/app/core/database/migrations/script.py.mako b/app/core/database/migrations/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/app/core/database/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py b/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py new file mode 100644 index 0000000..6741097 --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_14_1524-32d5607c5cc0_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 32d5607c5cc0 +Revises: +Create Date: 2025-04-14 15:24:41.833280 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "32d5607c5cc0" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "telegram_user", + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.Column("win_count", sa.Integer(), nullable=False), + sa.Column("loss_count", sa.Integer(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.CheckConstraint("loss_count >= 0", name="loss_count_non_negative"), + sa.CheckConstraint("win_count >= 0", name="win_count_non_negative"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("telegram_user") + # ### end Alembic commands ### diff --git a/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py b/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py new file mode 100644 index 0000000..5991d6f --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_18_1107-9b0891d63f2a_.py @@ -0,0 +1,199 @@ +"""empty message + +Revision ID: 9b0891d63f2a +Revises: 32d5607c5cc0 +Create Date: 2025-04-18 11:07:54.259471 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9b0891d63f2a" +down_revision: str | None = "32d5607c5cc0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "question", + sa.Column("text", sa.String(length=255), nullable=False), + sa.Column("answer", sa.String(length=255), nullable=False), + sa.Column("hard_level", sa.SmallInteger(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "round", + sa.Column( + "type", + sa.Enum("ROUND_1", "ROUND_2", "ROUND_3", name="roundtypeenum"), + nullable=False, + ), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "theme", + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "game", + sa.Column("chat_id", sa.BigInteger(), nullable=False), + sa.Column( + "status", + sa.Enum( + "LOBBY", + "ROUND_1", + "ROUND_2", + "ROUND_3", + "COMPLETED", + name="gamestatusenum", + ), + nullable=False, + ), + sa.Column("master_id", sa.BigInteger(), nullable=False), + sa.Column("active_user_id", sa.BigInteger(), nullable=False), + sa.Column("choice_user_id", sa.BigInteger(), nullable=False), + sa.Column("id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["active_user_id"], + ["telegram_user.id"], + ), + sa.ForeignKeyConstraint( + ["choice_user_id"], + ["telegram_user.id"], + ), + sa.ForeignKeyConstraint( + ["master_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "question_to_theme", + sa.Column("theme_id", sa.BigInteger(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column( + "status", + sa.Enum("NOT_ANSWERED", "ANSWERED", "WAIT_ANSWERED", name="answerstatusenum"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["theme_id"], + ["theme.id"], + ), + sa.PrimaryKeyConstraint("theme_id", "question_id"), + ) + op.create_table( + "telegram_user_to_round", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column( + "state", + sa.Enum("NOT_ANSWERED", "ANSWERED", "WAIT_ANSWERED", name="answerstatusenum"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("user_id", "question_id", "round_id"), + ) + op.create_table( + "theme_to_round", + sa.Column("theme_id", sa.BigInteger(), nullable=False), + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.ForeignKeyConstraint( + ["theme_id"], + ["theme.id"], + ), + sa.PrimaryKeyConstraint("theme_id", "round_id"), + ) + op.create_table( + "timers", + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column("create_at", sa.TIMESTAMP(), nullable=False), + sa.Column("question_id", sa.BigInteger(), nullable=False), + sa.Column("duration", sa.Interval(), nullable=False), + sa.ForeignKeyConstraint( + ["question_id"], + ["question.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.PrimaryKeyConstraint("round_id"), + ) + op.create_table( + "round_to_game", + sa.Column("round_id", sa.BigInteger(), nullable=False), + sa.Column("game_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["game.id"], + ), + sa.ForeignKeyConstraint( + ["round_id"], + ["round.id"], + ), + sa.PrimaryKeyConstraint("round_id", "game_id"), + ) + op.create_table( + "telegram_user_to_game", + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("game_id", sa.BigInteger(), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["game.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["telegram_user.id"], + ), + sa.PrimaryKeyConstraint("user_id", "game_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("telegram_user_to_game") + op.drop_table("round_to_game") + op.drop_table("timers") + op.drop_table("theme_to_round") + op.drop_table("telegram_user_to_round") + op.drop_table("question_to_theme") + op.drop_table("game") + op.drop_table("theme") + op.drop_table("round") + op.drop_table("question") + # ### end Alembic commands ### diff --git a/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py b/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py new file mode 100644 index 0000000..ae237fa --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_22_2053-ea84e7a02b20_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: ea84e7a02b20 +Revises: 9b0891d63f2a +Create Date: 2025-04-22 20:53:23.929599 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ea84e7a02b20" +down_revision: str | None = "9b0891d63f2a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("game", "active_user_id", existing_type=sa.BIGINT(), nullable=True) + op.alter_column("game", "choice_user_id", existing_type=sa.BIGINT(), nullable=True) + op.add_column("question", sa.Column("theme_id", sa.BigInteger(), nullable=False)) + op.create_foreign_key( + op.f("fk_question_theme_id_theme"), "question", "theme", ["theme_id"], ["id"] + ) + op.add_column( + "question_to_theme", sa.Column("round_id", sa.BigInteger(), nullable=False) + ) + op.create_foreign_key( + op.f("fk_question_to_theme_round_id_round"), + "question_to_theme", + "round", + ["round_id"], + ["id"], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_question_to_theme_round_id_round"), + "question_to_theme", + type_="foreignkey", + ) + op.drop_column("question_to_theme", "round_id") + op.drop_constraint( + op.f("fk_question_theme_id_theme"), "question", type_="foreignkey" + ) + op.drop_column("question", "theme_id") + op.alter_column("game", "choice_user_id", existing_type=sa.BIGINT(), nullable=False) + op.alter_column("game", "active_user_id", existing_type=sa.BIGINT(), nullable=False) + # ### end Alembic commands ### diff --git a/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py b/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py new file mode 100644 index 0000000..f343cdb --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_23_1609-17651c57241b_.py @@ -0,0 +1,31 @@ +"""empty message + +Revision ID: 17651c57241b +Revises: 6d7c9baf7b92 +Create Date: 2025-04-23 16:09:59.934549 + +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "17651c57241b" +down_revision: str | None = "ea84e7a02b20" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade(): + op.drop_constraint("pk_question_to_theme", "question_to_theme", type_="primary") + op.create_primary_key( + "pk_question_to_theme", "question_to_theme", ["round_id", "theme_id", "question_id"] + ) + + +def downgrade(): + op.drop_constraint("pk_question_to_theme", "question_to_theme", type_="primary") + op.create_primary_key( + "pk_question_to_theme", "question_to_theme", ["theme_id", "question_id"] + ) diff --git a/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py b/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py new file mode 100644 index 0000000..b158afe --- /dev/null +++ b/app/core/database/migrations/versions/2025_04_23_2211-a0f9c95c9a02_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: a0f9c95c9a02 +Revises: 17651c57241b +Create Date: 2025-04-23 22:11:50.138601 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a0f9c95c9a02" +down_revision: str | None = "17651c57241b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "admin_model", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("password", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_admin_model")), + sa.UniqueConstraint("email", name=op.f("uq_admin_model_email")), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("admin_model") + # ### end Alembic commands ### diff --git a/app/core/database/mixins.py b/app/core/database/mixins.py new file mode 100644 index 0000000..a7afaae --- /dev/null +++ b/app/core/database/mixins.py @@ -0,0 +1,6 @@ +from sqlalchemy import BigInteger +from sqlalchemy.orm import Mapped, mapped_column + + +class IDMixin: + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) diff --git a/app/core/database/sqlalchemy_base.py b/app/core/database/sqlalchemy_base.py new file mode 100644 index 0000000..92c6627 --- /dev/null +++ b/app/core/database/sqlalchemy_base.py @@ -0,0 +1,14 @@ +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + + +class BaseModel(DeclarativeBase): + metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_N_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", + }, + ) diff --git a/app/core/manager.py b/app/core/manager.py new file mode 100644 index 0000000..2142705 --- /dev/null +++ b/app/core/manager.py @@ -0,0 +1,39 @@ +from collections.abc import Awaitable, Callable + +import aio_pika +from aio_pika import Message +from aio_pika.abc import AbstractIncomingMessage + + +class RabbitMQManager: + def __init__(self, amqp_url: str, queue_name: str): + self._url = amqp_url + self._queue_name = queue_name + self._connection: aio_pika.abc.AbstractRobustConnection | None = None + self._channel: aio_pika.abc.AbstractChannel | None = None + self._queue: aio_pika.abc.AbstractQueue | None = None + + async def connect(self) -> None: + self._connection = await aio_pika.connect_robust(self._url) + self._channel = await self._connection.channel() + self._queue = await self._channel.declare_queue(self._queue_name, durable=True) + + async def close(self) -> None: + if self._connection: + await self._connection.close() + + async def send(self, body: bytes) -> None: + if self._channel is None: + raise RuntimeError("RabbitMQManager is not connected") + await self._channel.default_exchange.publish( + Message(body=body), + routing_key=self._queue_name, + ) + + async def consume( + self, + handler: Callable[[AbstractIncomingMessage], Awaitable[None]], + ) -> None: + if self._queue is None: + raise RuntimeError("RabbitMQManager is not connected") + await self._queue.consume(handler) diff --git a/app/core/session.py b/app/core/session.py new file mode 100644 index 0000000..c471eea --- /dev/null +++ b/app/core/session.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +from aiohttp_session import setup as aiohttp_setup_session +from aiohttp_session.cookie_storage import EncryptedCookieStorage +from cryptography import fernet + +if TYPE_CHECKING: + from app.app import Application + + +def setup_session(app: 'Application', key: str) -> None: + f_key = fernet.Fernet(key) + aiohttp_setup_session(app, EncryptedCookieStorage(f_key)) diff --git a/app/fixtures/__init__.py b/app/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/fixtures/data.json b/app/fixtures/data.json new file mode 100644 index 0000000..cc8bafc --- /dev/null +++ b/app/fixtures/data.json @@ -0,0 +1,224 @@ +[ + { + "model": "theme", + "fields": { + "title": "Наука", + "id": 1 + } + }, + { + "model": "theme", + "fields": { + "title": "География", + "id": 2 + } + }, + { + "model": "theme", + "fields": { + "title": "Литература", + "id": 3 + } + }, + { + "model": "theme", + "fields": { + "title": "Кино", + "id": 4 + } + }, + { + "model": "theme", + "fields": { + "title": "Технологии", + "id": 5 + } + }, + { + "model": "theme", + "fields": { + "title": "Музыка", + "id": 6 + } + }, + { + "model": "question", + "fields": { + "text": "Какой химический элемент обозначается символом \"O\"?", + "answer": "Кислород", + "hard_level": 1, + "theme_id": 1, + "id": 1 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется наука, изучающая строение и свойства вещества?", + "answer": "Химия", + "hard_level": 2, + "theme_id": 1, + "id": 2 + } + }, + { + "model": "question", + "fields": { + "text": "Какой физик впервые сформулировал три закона движения?", + "answer": "Исаак Ньютон", + "hard_level": 3, + "theme_id": 1, + "id": 3 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется самая большая страна в мире по площади?", + "answer": "Россия", + "hard_level": 1, + "theme_id": 2, + "id": 4 + } + }, + { + "model": "question", + "fields": { + "text": "Через какие два континента проходит Турция?", + "answer": "Азия и Европа", + "hard_level": 2, + "theme_id": 2, + "id": 5 + } + }, + { + "model": "question", + "fields": { + "text": "Какое море не имеет выхода к океану и полностью окружено сушей?", + "answer": "Каспийское море", + "hard_level": 3, + "theme_id": 2, + "id": 6 + } + }, + { + "model": "question", + "fields": { + "text": "Кто написал роман \"Война и мир\"?", + "answer": "Лев Толстой", + "hard_level": 1, + "theme_id": 3, + "id": 7 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется произведение, начинающееся строкой: \"У лукоморья дуб зелёный...\"?", + "answer": "\"Руслан и Людмила\" — А.С. Пушкин", + "hard_level": 2, + "theme_id": 3, + "id": 8 + } + }, + { + "model": "question", + "fields": { + "text": "Кто автор романа \"Преступление и наказание\"?", + "answer": "Фёдор Достоевский", + "hard_level": 3, + "theme_id": 3, + "id": 9 + } + }, + { + "model": "question", + "fields": { + "text": "Как зовут волшебника, наставника Гарри Поттера?", + "answer": "Альбус Дамблдор", + "hard_level": 1, + "theme_id": 4, + "id": 10 + } + }, + { + "model": "question", + "fields": { + "text": "Какой фильм получил «Оскар» за лучший фильм в 1997 году и рассказывал о затонувшем лайнере?", + "answer": "Титаник", + "hard_level": 2, + "theme_id": 4, + "id": 11 + } + }, + { + "model": "question", + "fields": { + "text": "Кто сыграл главную роль в фильме \"Начало\" (Inception)?", + "answer": "Леонардо Ди Каприо", + "hard_level": 3, + "theme_id": 4, + "id": 12 + } + }, + { + "model": "question", + "fields": { + "text": "Что означает сокращение \"USB\"?", + "answer": "Universal Serial Bus", + "hard_level": 1, + "theme_id": 5, + "id": 13 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется самая популярная операционная система от Microsoft?", + "answer": "Windows", + "hard_level": 2, + "theme_id": 5, + "id": 14 + } + }, + { + "model": "question", + "fields": { + "text": "Какой алгоритм хэширования используется в блокчейне Bitcoin?", + "answer": "SHA-256", + "hard_level": 3, + "theme_id": 5, + "id": 15 + } + }, + { + "model": "question", + "fields": { + "text": " Сколько струн у стандартной гитары?", + "answer": "Шесть", + "hard_level": 1, + "theme_id": 6, + "id": 16 + } + }, + { + "model": "question", + "fields": { + "text": "Какой композитор написал «Лунную сонату»?", + "answer": "Людвиг ван Бетховен", + "hard_level": 2, + "theme_id": 6, + "id": 17 + } + }, + { + "model": "question", + "fields": { + "text": "Как называется музыкальный стиль, зародившийся в США в 1970-х и характеризующийся рифмованным речитативом под бит?", + "answer": "Хип-хоп", + "hard_level": 3, + "theme_id": 6, + "id": 18 + } + } +] \ No newline at end of file diff --git a/app/fixtures/fixtures.py b/app/fixtures/fixtures.py new file mode 100644 index 0000000..10a65c5 --- /dev/null +++ b/app/fixtures/fixtures.py @@ -0,0 +1,228 @@ +import argparse +import asyncio +import contextlib +import importlib +import inspect +import json +import logging +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiofiles +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from typing_extensions import TypedDict + +from app.app import setup_app +from app.core.accessor import transaction +from app.core.database.database import BaseModel + +app = setup_app() +app.database.connect() + + +class FieldsDict(TypedDict): + pass + + +class FixtureItem(TypedDict): + model: str + fields: dict[str, Any] + + +MODEL_MAP: dict[str, type[BaseModel]] = {} + + +class DateTimeEncoder(json.JSONEncoder): + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + return obj.isoformat() + return super().default(obj) + + +def find_db_files(start_dir: str) -> list[str]: + db_files: list[str] = [] + + for root, _dirs, files in os.walk(start_dir): + if "models.py" in files: + db_files.append(str(Path(root) / "models.py")) + + return db_files + + +def import_modules( + db_files: list[str], + start_dir: str, + logger: "logging.Logger", +) -> None: + for db_file in db_files: + rel_path: str = os.path.relpath(db_file, start_dir) + module_name: str = "app." + rel_path.replace(os.sep, ".")[:-3] + + try: + importlib.import_module(module_name) + except ImportError: + logger.exception("Ошибка импорта модуля %s", module_name) + + +def get_model_classes() -> dict[str, type[BaseModel]]: + model_classes: dict[str, type[BaseModel]] = {} + + for module_name, module in sys.modules.items(): + if module_name.startswith("app."): + for _name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, BaseModel) + and obj is not BaseModel + ): + model_classes[obj.__tablename__] = obj + + return model_classes + + +async def dump_data( + logger: "logging.Logger", + model_map: dict[str, type[BaseModel]], + file_path: str, + models: list[str] | None = None, +) -> None: + + if models is None: + models = list(model_map.keys()) + + data: list[FixtureItem] = [] + + async with app.accessors.base_accessor.session() as session: + for model_name in models: + if model_name not in model_map: + logger.warning("Модель %s не найдена", model_name) + continue + + model_class: type[BaseModel] = model_map[model_name] + result = await session.execute(select(model_class)) + objects = result.scalars().all() + + for obj in objects: + fields: dict[str, Any] = { + col.name: getattr(obj, col.name) for col in obj.__table__.columns + } + data.append({"model": model_name, "fields": fields}) + + async with aiofiles.open(file_path, "w", encoding="utf-8") as f: + await f.write( + json.dumps(data, indent=4, ensure_ascii=False, cls=DateTimeEncoder), + ) + + logger.info("Данные успешно выгружены в %s", file_path) + + +async def load_data( + logger: "logging.Logger", + model_map: dict[str, type[BaseModel]], + file_path: str, + *, + clear_before: bool = False, +) -> None: + + async with aiofiles.open(file_path, encoding="utf-8") as f: + content = await f.read() + + data: list[FixtureItem] = json.loads(content) + + data_by_model: defaultdict[str, list[FixtureItem]] = defaultdict(list) + + for item in data: + model_name: str = item["model"] + if model_name in model_map: + item_fields: dict[str, Any] = item["fields"] + for key, value in item_fields.items(): + if isinstance(value, str) and value: + with contextlib.suppress(ValueError): + item_fields[key] = datetime.fromisoformat(value) + item["fields"] = item_fields + data_by_model[model_name].append(item) + else: + logger.warning("Модель %s не найдена в базе данных", model_name) + + async with transaction(app.accessors.base_accessor) as session: + if clear_before: + for model_class in model_map.values(): + await session.execute(delete(model_class)) + logger.info("Все таблицы очищены перед загрузкой") + + for model_name, items in data_by_model.items(): + model_cls: type[BaseModel] = model_map[model_name] + for item in items: + fields: dict[str, Any] = item["fields"] + obj: BaseModel = model_cls(**fields) + session.add(obj) + + logger.info("Данные успешно загружены из %s", file_path) + + +async def main() -> None: + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Утилита для работы с данными базы", + ) + subparsers = parser.add_subparsers(dest="command") + + dump_parser: argparse.ArgumentParser = subparsers.add_parser( + "dump", + help="Выгрузить данные в JSON", + ) + dump_parser.add_argument("file_path", type=str, help="Путь к JSON-файлу") + dump_parser.add_argument( + "--models", + nargs="*", + type=str, + help="Список моделей (по умолчанию все)", + ) + + load_parser: argparse.ArgumentParser = subparsers.add_parser( + "load", + help="Загрузить данные из JSON", + ) + load_parser.add_argument("file_path", type=str, help="Путь к JSON-файлу") + load_parser.add_argument( + "--clear", + action="store_true", + help="Очистить базу перед загрузкой", + ) + + args: argparse.Namespace = parser.parse_args() + + logger = logging.getLogger(__name__) + + start_dir: str = ".." + db_files: list[str] = find_db_files(start_dir) + import_modules(db_files, start_dir, logger) + + model_map = get_model_classes() + + if args.command == "dump": + models: list[str] | None = args.models or None + await dump_data(logger, model_map, args.file_path, models) + elif args.command == "load": + await load_data(logger, model_map, args.file_path, clear_before=args.clear) + else: + logger.info("Команда не указана. Используйте --help для справки") + parser.print_help() + + await app.database.disconnect() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + try: + asyncio.run(main()) + except IntegrityError: + sys.exit(0) diff --git a/app/poller/Dockerfile b/app/poller/Dockerfile new file mode 100644 index 0000000..dc23a35 --- /dev/null +++ b/app/poller/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-alpine +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /project +COPY pyproject.toml uv.lock ./ + +RUN uv sync --compile-bytecode --no-cache --no-dev +ENV PATH="/project/.venv/bin:$PATH" + +COPY . . + +CMD python -m app.poller.poller diff --git a/app/poller/__init__.py b/app/poller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/poller/poller.py b/app/poller/poller.py new file mode 100644 index 0000000..47b8d0c --- /dev/null +++ b/app/poller/poller.py @@ -0,0 +1,34 @@ +import asyncio +import json +from typing import Any, cast + +import aiohttp + +from app.app import app, setup_app +from app.core.manager import RabbitMQManager + + +async def get_updates(url: str, offset: int) -> dict[str, Any]: + async with aiohttp.ClientSession() as session: + async with session.get(url, params={"offset": offset, "timeout": 30}) as resp: + return cast(dict[str, Any], await resp.json()) + + +async def poll_and_push() -> None: + rabbit = RabbitMQManager( + amqp_url=app.config.rabbitmq.url, + queue_name=app.config.rabbitmq.input_queue, + ) + await rabbit.connect() + + offset = 0 + while True: + data = await get_updates(app.bot_api.build_method_url("getUpdates"), offset) + for update in data.get("result", []): + offset = update["update_id"] + 1 + await rabbit.send(json.dumps(update).encode()) + + +if __name__ == "__main__": + setup_app() + asyncio.run(poll_and_push()) diff --git a/app/store/__init__.py b/app/store/__init__.py deleted file mode 100644 index 7315ae4..0000000 --- a/app/store/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .store import Store - -__all__ = ("Store",) diff --git a/app/store/store.py b/app/store/store.py deleted file mode 100644 index f811871..0000000 --- a/app/store/store.py +++ /dev/null @@ -1,5 +0,0 @@ -class Store: - def __init__(self, *args, **kwargs): - from app.users.accessor import UserAccessor - - self.user = UserAccessor(self) diff --git a/app/users/accessor.py b/app/users/accessor.py deleted file mode 100644 index 364b466..0000000 --- a/app/users/accessor.py +++ /dev/null @@ -1,3 +0,0 @@ -class UserAccessor: - def __init__(self, config) -> None: - self.config = config diff --git a/app/users/routes.py b/app/users/routes.py deleted file mode 100644 index 2ac0d35..0000000 --- a/app/users/routes.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiohttp.web_app import Application - -__all__ = ("register_urls",) - - -def register_urls(application: Application): - pass diff --git a/app/users/schema.py b/app/users/schema.py deleted file mode 100644 index 42163d1..0000000 --- a/app/users/schema.py +++ /dev/null @@ -1,5 +0,0 @@ -from marshmallow import Schema - - -class UserSchema(Schema): - pass diff --git a/app/web/app.py b/app/web/app.py deleted file mode 100644 index 4e98114..0000000 --- a/app/web/app.py +++ /dev/null @@ -1,21 +0,0 @@ -from aiohttp.web import ( - Application as AiohttpApplication, -) - -from .routes import setup_routes - -__all__ = ("Application",) - - -class Application(AiohttpApplication): - config = None - store = None - database = None - - -app = Application() - - -def setup_app(config_path: str) -> Application: - setup_routes(app) - return app diff --git a/app/web/mw.py b/app/web/mw.py deleted file mode 100644 index 5a8f370..0000000 --- a/app/web/mw.py +++ /dev/null @@ -1,7 +0,0 @@ -from aiohttp import web -from aiohttp.abc import Request - - -@web.middleware -async def example_mw(request: Request, handler): - return await handler(request) diff --git a/app/web/routes.py b/app/web/routes.py deleted file mode 100644 index 46f64ad..0000000 --- a/app/web/routes.py +++ /dev/null @@ -1,9 +0,0 @@ -from aiohttp.web_app import Application - -__all__ = ("setup_routes",) - - -def setup_routes(application: Application): - import app.users.routes - - app.users.routes.register_urls(application) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..48bf253 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +services: + postgres: + image: postgres:17.4-alpine + ports: + - ${DATABASE__PORT}:${DATABASE__PORT} + env_file: + - .env + environment: + - POSTGRES_USER=${DATABASE__USER} + - POSTGRES_PASSWORD=${DATABASE__PASSWORD} + - POSTGRES_DB=${DATABASE__DATABASE} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + command: -p ${DATABASE__PORT} + + rabbitmq: + image: rabbitmq:4.0.8-alpine + env_file: + - .env + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ__USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ__PASSWORD} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + + migrator: + build: + context: . + dockerfile: app/admin/Dockerfile + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + command: sh -c "alembic upgrade head && python -m app.fixtures.fixtures load ./app/fixtures/data.json" + + poller: + build: + context: . + dockerfile: app/poller/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + bot: + build: + context: . + dockerfile: app/bot/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + admin: + build: + context: . + dockerfile: app/admin/Dockerfile + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always diff --git a/etc/config.yaml b/etc/config.yaml deleted file mode 100644 index 1daee74..0000000 --- a/etc/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -debug: true -web: - host: 127.0.0.1 - port: 8000 - -sentry: - dsn: - env: dev - -store: {} diff --git a/prod.docker-compose.yml b/prod.docker-compose.yml new file mode 100644 index 0000000..4f659b6 --- /dev/null +++ b/prod.docker-compose.yml @@ -0,0 +1,80 @@ +services: + postgres: + image: postgres:17.4-alpine + env_file: + - .env + environment: + - POSTGRES_USER=${DATABASE__USER} + - POSTGRES_PASSWORD=${DATABASE__PASSWORD} + - POSTGRES_DB=${DATABASE__DATABASE} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + command: -p ${DATABASE__PORT} + + rabbitmq: + image: rabbitmq:4.0.8-alpine + env_file: + - .env + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ__USER} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ__PASSWORD} + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 2s + timeout: 5s + retries: 10 + restart: always + + migrator: + image: grayadvantage/jeopardy-migrator:latest + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + command: sh -c "alembic upgrade head && python -m app.fixtures.fixtures load ./app/fixtures/data.json" + + poller: + image: grayadvantage/jeopardy-poller:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + bot: + image: grayadvantage/jeopardy-bot:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always + + admin: + build: + context: . + dockerfile: grayadvantage/jeopardy-admin:latest + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + migrator: + condition: service_completed_successfully + env_file: + - .env + restart: always diff --git a/pyproject.toml b/pyproject.toml index 46e54fa..fd4e7f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,60 +1,61 @@ +[project] +name = "JeopardyBot" +version = "0.0.1" +description = "Телеграмм бот игра `Своя игра` — итоговый проект для курса KTS" +readme = "README.md" +requires-python = "==3.12.*" + +dependencies = [ + "aiohttp==3.11.16", + "aiohttp_apispec==3.0.0b2", + "aiohttp_cors==0.8.1", + "aiohttp_session==2.12.1", + "alembic==1.15.2", + "asyncpg==0.30.0", + "cryptography==44.0.1", + "greenlet==3.1.1", + "pydantic==2.11.3", + "pydantic_settings==2.8.1", + "PyYAML==6.0.2", + "SQLAlchemy==2.0.40", + "aiosignal==1.3.2", + "apispec==6.8.1", + "attrs==25.3.0", + "cffi==1.17.1", + "frozenlist==1.5.0", + "idna==3.10", + "iniconfig==2.1.0", + "multidict==6.4.3", + "packaging==24.2", + "pluggy==1.5.0", + "pycparser==2.22", + "typing_extensions==4.13.2", + "webargs==8.6.0", + "yarl==1.19.0", + "aio-pika==9.5.5", + "aiofiles>=24.1.0", +] + +[dependency-groups] +dev = [ + "mypy==1.15.0", + "pytest==8.3.5", + "pytest-aiohttp==1.1.0", + "pytest-asyncio==0.26.0", + "pre-commit==4.2.0", + "ruff==0.11.5" +] + [tool.ruff] -line-length = 80 -indent-width = 4 target-version = "py312" -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".gitlab", - ".git-rewrite", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "alembic", - "_build", - "buck-out", - "build", - "dist", - "docker", - "env", - "etc", - "requirements", - "venv" -] [tool.ruff.format] -# Аналогично black, двойные кавычки quote-style = "double" - -# Аналогично black, пробелы вместо табов -indent-style = "space" - -# Аналогично black, уважаем trailing commas -skip-magic-trailing-comma = false - -# Аналогично black, автоматически определяем подходящее окончание строки. line-ending = "auto" [tool.ruff.lint] -# Список кодов или префиксов правил, которые следует считать исправляемыми. (https://docs.astral.sh/ruff/settings/#fixable) -# По умолчанию все правила считаются исправляемыми. -fixable = ["I", "RUF022", "RUF023"] preview = true - -# Правила, которые следует добавить к указанным в "select=[]" конфига или "--select" cli-команды extend-select = [ - # Включил все правила, которые дают определенный профит - "ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async "A001", # https://docs.astral.sh/ruff/rules/builtin-variable-shadowing "B", # https://docs.astral.sh/ruff/rules/builtin-argument-shadowing @@ -84,62 +85,21 @@ extend-select = [ "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 - "TRIO", # https://docs.astral.sh/ruff/rules/#flake8-trio-trio + "ASYNC1", # Было заменено на новый "TRY300", # https://docs.astral.sh/ruff/rules/try-consider-else - "TRY302", # https://docs.astral.sh/ruff/rules/useless-try-except + "TRY203", # Было заменено на новый "TRY401", # https://docs.astral.sh/ruff/rules/verbose-log-message "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up "YTT", # https://docs.astral.sh/ruff/rules/#flake8-2020-ytt ] - -# Правила, которые следует добавить к указанным в "ignore=[]" конфига или "--ignore" команды +# По мере "бесячих" ошибок буду дополнять, а так, всё строго extend-ignore = [ - # Могут быть излишними в нашей ситуации - "D1", # https://docs.astral.sh/ruff/rules/#pydocstyle-d - "D205", # https://docs.astral.sh/ruff/rules/blank-line-after-summary - "D415", # https://docs.astral.sh/ruff/rules/ends-in-punctuation - - "PLR2004", # https://docs.astral.sh/ruff/rules/magic-value-comparison – много ложных срабатываний - "PLR0904", # https://docs.astral.sh/ruff/rules/too-many-public-methods – неактуально для нашего аксессорного подхода - "PLR0917", # https://docs.astral.sh/ruff/rules/too-many-positional – может оказатся слишком жесткис - "PLR6201", # https://docs.astral.sh/ruff/rules/literal-membership – не всегда имеет смысл использовать set - "PLR6301", # https://docs.astral.sh/ruff/rules/no-self-use – неактуально для нашего аксессорного подхода - "PLW1514", # https://docs.astral.sh/ruff/rules/unspecified-encoding – неактуально для наших проектов - "PLW1641", # https://docs.astral.sh/ruff/rules/eq-without-hash – иногда нужен eq без hash - "PLR0913", # https://docs.astral.sh/ruff/rules/too-many-arguments – может оказаться слишком жестко для ребят - "PLR1714", # https://docs.astral.sh/ruff/rules/repeated-equality-comparison – бывают неадекватные срабатывания - "PERF203", # https://docs.astral.sh/ruff/rules/try-except-in-loop – я бы включил, но сами таким грешим, не думаю, что крит - - # нигде не придерживались такого подхода - "PT004", # https://docs.astral.sh/ruff/rules/pytest-missing-fixture-name-underscore - "PT005", # https://docs.astral.sh/ruff/rules/pytest-incorrect-fixture-name-underscore - - "PT007", # https://docs.astral.sh/ruff/rules/pytest-parametrize-values-wrong-type – не считаю критичным - - # выключил, чтобы продвигать русский язык - "RUF001", # https://docs.astral.sh/ruff/rules/ambiguous-unicode-character-string - "RUF002", # https://docs.astral.sh/ruff/rules/ambiguous-unicode-character-docstring - "RUF003", # https://docs.astral.sh/ruff/rules/ambiguous-unicode-character-comment - - # не считаю критичным - "RUF012", # https://docs.astral.sh/ruff/rules/mutable-class-default - "RUF021", # https://docs.astral.sh/ruff/rules/parenthesize-chained-operators - - - "SIM105", # https://docs.astral.sh/ruff/rules/suppressible-exception – я бы включил, но мало кто знает про это и пользуется - "SIM108", # https://docs.astral.sh/ruff/rules/if-else-block-instead-of-if-exp – много ложных срабатываний - "SIM117", # https://docs.astral.sh/ruff/rules/multiple-with-statements – часто становится хуже - - # не считаю критичным - "UP012", # https://docs.astral.sh/ruff/rules/unnecessary-encode-utf8 - "UP015", # https://docs.astral.sh/ruff/rules/redundant-open-modes - - "UP032", # https://docs.astral.sh/ruff/rules/f-string – иногда уместнее использовать str.format - - - # [!] При использовании Ruff в качестве форматтера, рекомендуется избегать следующих правил: - # (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules) + "D1", + "CPY001", + "SIM117", + "SIM114", + # По рекомендации https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", "E111", "E114", @@ -157,44 +117,29 @@ extend-ignore = [ ] [tool.ruff.lint.extend-per-file-ignores] -# PLC0415 https://docs.astral.sh/ruff/rules/import-outside-top-level – мы сами такое используем в некоторых местах -# F403 https://docs.astral.sh/ruff/rules/undefined-local-with-import-star – wildcards в init.py и тестах терпимо -# F405 https://docs.astral.sh/ruff/rules/undefined-local-with-import-star-usage – wildcards в тестах терпимо -# SIM300 https://docs.astral.sh/ruff/rules/yoda-conditions – разрешаем йоду для ассертов - "__init__.py" = ["F403", "PLC0415"] "routes.py" = ["PLC0415"] "urls.py" = ["PLC0415"] "store.py" = ["PLC0415"] "tests/*.py" = ["SIM300", "F403", "F405", "INP001"] +"*/versions/*.py" = ["D415", "INP001"] [tool.ruff.lint.pydocstyle] -# Требуем google-docstring, т.к. на большинстве проектах придерживаемся этого формата convention = "google" - [tool.ruff.lint.isort] -# Объекдиняем импорты из одного пакета combine-as-imports = true - [tool.ruff.lint.flake8-unused-arguments] -# Считаем терпимым не использовать *args, **kwargs ignore-variadic-names = true - [tool.ruff.lint.flake8-pytest-style] -# Делаем единый стиль скобок в тестах fixture-parentheses = false mark-parentheses = false -[tool.pytest.ini_options] -asyncio_mode="auto" -filterwarnings = [ - "ignore::DeprecationWarning:asyncpg.*:", - "ignore::DeprecationWarning:pytest_asyncio.plugin.*:", - "ignore::DeprecationWarning", -] - +[tool.mypy] +strict = true # Ещё строже! +python_version = "3.12" +plugins = ['pydantic.mypy'] diff --git a/requirements.txt b/requirements.txt index fc4983d..6516db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,33 +1,32 @@ -aiohttp==3.9.3 +aiofiles==24.1.0 +aiohttp==3.11.16 aiohttp_apispec==3.0.0b2 -aiohttp_cors==0.7.0 -aiohttp_session==2.12.0 -alembic==1.13.1 -asyncpg==0.29.0 -cryptography==42.0.5 -greenlet==3.0.3 -marshmallow==3.21.0 -pytest==8.0.2 -pytest-aiohttp==1.0.5 -pytest-asyncio==0.23.5 -PyYAML==6.0.1 -ruff==0.4.2 -SQLAlchemy==2.0.27 +aiohttp_cors==0.8.1 +aiohttp_session==2.12.1 +aio-pika==9.5.5 +alembic==1.15.2 +asyncpg==0.30.0 +cryptography==44.0.1 +greenlet==3.1.1 +mypy==1.15.0 +pydantic==2.11.3 +pydantic_settings==2.8.1 +PyYAML==6.0.2 +pre-commit==4.2.0 +ruff==0.11.5 +SQLAlchemy==2.0.40 -aiosignal==1.3.1 -apispec==6.6.0 -attrs==23.2.0 -cffi==1.16.0 -frozenlist==1.4.1 -idna==3.6 -iniconfig==2.0.0 -Jinja2==3.1.3 -Mako==1.3.2 -MarkupSafe==2.1.5 -multidict==6.0.5 -packaging==24.0 -pluggy==1.4.0 -pycparser==2.21 -typing_extensions==4.10.0 -webargs==8.4.0 -yarl==1.9.4 +aiosignal==1.3.2 +apispec==6.8.1 +attrs==25.3.0 +cffi==1.17.1 +frozenlist==1.5.0 +idna==3.10 +iniconfig==2.1.0 +multidict==6.4.3 +packaging==24.2 +pluggy==1.5.0 +pycparser==2.22 +typing_extensions==4.13.2 +webargs==8.6.0 +yarl==1.19.0 diff --git a/template.env b/template.env new file mode 100644 index 0000000..52b2316 --- /dev/null +++ b/template.env @@ -0,0 +1,12 @@ +BOT__TOKEN=0123456789:a1b2c3d4e5f6g7h8i9jklmnopqrstuvwxyz + +DATABASE__HOST=localhost +DATABASE__PORT=5432 +DATABASE__USER=postgres +DATABASE__PASSWORD=postgres +DATABASE__DATABASE=postgres + +RABBITMQ__HOST=localhost +RABBITMQ__PORT=5672 +RABBITMQ__USER=guest +RABBITMQ__PASSWORD=guest diff --git a/tests/config.yaml b/tests/config.yaml deleted file mode 100644 index 45a4ab5..0000000 --- a/tests/config.yaml +++ /dev/null @@ -1,4 +0,0 @@ -debug: true - -security: - token: token diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 8b13789..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0dccc32 --- /dev/null +++ b/uv.lock @@ -0,0 +1,894 @@ +version = 1 +revision = 1 +requires-python = "==3.12.*" + +[[package]] +name = "aio-pika" +version = "9.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "exceptiongroup" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/00/5391405f15e85bd6cb859186dbe04d99186ca29410a7cdc52848b55a1d72/aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4", size = 48468 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cf/efa5581760bd08263bce8dbf943f32006b6dfd5bc120f43a26257281b546/aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0", size = 54257 }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/1c4721d143e14af753f2bf5e3b681883e1f24b592c0482df6fa6e33597fa/aiohttp-3.11.16.tar.gz", hash = "sha256:16f8a2c9538c14a557b4d309ed4d0a7c60f0253e8ed7b6c9a2859a7582f8b1b8", size = 7676826 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/38/100d01cbc60553743baf0fba658cb125f8ad674a8a771f765cdc155a890d/aiohttp-3.11.16-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:911a6e91d08bb2c72938bc17f0a2d97864c531536b7832abee6429d5296e5b27", size = 704881 }, + { url = "https://files.pythonhosted.org/packages/21/ed/b4102bb6245e36591209e29f03fe87e7956e54cb604ee12e20f7eb47f994/aiohttp-3.11.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac13b71761e49d5f9e4d05d33683bbafef753e876e8e5a7ef26e937dd766713", size = 464564 }, + { url = "https://files.pythonhosted.org/packages/3b/e1/a9ab6c47b62ecee080eeb33acd5352b40ecad08fb2d0779bcc6739271745/aiohttp-3.11.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd36c119c5d6551bce374fcb5c19269638f8d09862445f85a5a48596fd59f4bb", size = 456548 }, + { url = "https://files.pythonhosted.org/packages/80/ad/216c6f71bdff2becce6c8776f0aa32cb0fa5d83008d13b49c3208d2e4016/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d489d9778522fbd0f8d6a5c6e48e3514f11be81cb0a5954bdda06f7e1594b321", size = 1691749 }, + { url = "https://files.pythonhosted.org/packages/bd/ea/7df7bcd3f4e734301605f686ffc87993f2d51b7acb6bcc9b980af223f297/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69a2cbd61788d26f8f1e626e188044834f37f6ae3f937bd9f08b65fc9d7e514e", size = 1736874 }, + { url = "https://files.pythonhosted.org/packages/51/41/c7724b9c87a29b7cfd1202ec6446bae8524a751473d25e2ff438bc9a02bf/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd464ba806e27ee24a91362ba3621bfc39dbbb8b79f2e1340201615197370f7c", size = 1786885 }, + { url = "https://files.pythonhosted.org/packages/86/b3/f61f8492fa6569fa87927ad35a40c159408862f7e8e70deaaead349e2fba/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce63ae04719513dd2651202352a2beb9f67f55cb8490c40f056cea3c5c355ce", size = 1698059 }, + { url = "https://files.pythonhosted.org/packages/ce/be/7097cf860a9ce8bbb0e8960704e12869e111abcd3fbd245153373079ccec/aiohttp-3.11.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b00dd520d88eac9d1768439a59ab3d145065c91a8fab97f900d1b5f802895e", size = 1626527 }, + { url = "https://files.pythonhosted.org/packages/1d/1d/aaa841c340e8c143a8d53a1f644c2a2961c58cfa26e7b398d6bf75cf5d23/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f6428fee52d2bcf96a8aa7b62095b190ee341ab0e6b1bcf50c615d7966fd45b", size = 1644036 }, + { url = "https://files.pythonhosted.org/packages/2c/88/59d870f76e9345e2b149f158074e78db457985c2b4da713038d9da3020a8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13ceac2c5cdcc3f64b9015710221ddf81c900c5febc505dbd8f810e770011540", size = 1685270 }, + { url = "https://files.pythonhosted.org/packages/2b/b1/c6686948d4c79c3745595efc469a9f8a43cab3c7efc0b5991be65d9e8cb8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fadbb8f1d4140825069db3fedbbb843290fd5f5bc0a5dbd7eaf81d91bf1b003b", size = 1650852 }, + { url = "https://files.pythonhosted.org/packages/fe/94/3e42a6916fd3441721941e0f1b8438e1ce2a4c49af0e28e0d3c950c9b3c9/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6a792ce34b999fbe04a7a71a90c74f10c57ae4c51f65461a411faa70e154154e", size = 1704481 }, + { url = "https://files.pythonhosted.org/packages/b1/6d/6ab5854ff59b27075c7a8c610597d2b6c38945f9a1284ee8758bc3720ff6/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f4065145bf69de124accdd17ea5f4dc770da0a6a6e440c53f6e0a8c27b3e635c", size = 1735370 }, + { url = "https://files.pythonhosted.org/packages/73/2a/08a68eec3c99a6659067d271d7553e4d490a0828d588e1daa3970dc2b771/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa73e8c2656a3653ae6c307b3f4e878a21f87859a9afab228280ddccd7369d71", size = 1697619 }, + { url = "https://files.pythonhosted.org/packages/61/d5/fea8dbbfb0cd68fbb56f0ae913270a79422d9a41da442a624febf72d2aaf/aiohttp-3.11.16-cp312-cp312-win32.whl", hash = "sha256:f244b8e541f414664889e2c87cac11a07b918cb4b540c36f7ada7bfa76571ea2", size = 411710 }, + { url = "https://files.pythonhosted.org/packages/33/fb/41cde15fbe51365024550bf77b95a4fc84ef41365705c946da0421f0e1e0/aiohttp-3.11.16-cp312-cp312-win_amd64.whl", hash = "sha256:23a15727fbfccab973343b6d1b7181bfb0b4aa7ae280f36fd2f90f5476805682", size = 438012 }, +] + +[[package]] +name = "aiohttp-apispec" +version = "3.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "apispec" }, + { name = "jinja2" }, + { name = "webargs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/56/710384c775672b92566e988b1a2b8fa4e1cb9a968f58f02fed4de64d72a1/aiohttp-apispec-3.0.0b2.tar.gz", hash = "sha256:9e678d400cfb25024b15d26e2012a87e1621f9a71f78009d029891b2e343ca85", size = 2653390 } + +[[package]] +name = "aiohttp-cors" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231 }, +] + +[[package]] +name = "aiohttp-session" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c4/d73a7f19b1bd3149ba5bccd22e3ab580c19e4d9fcb83114309e8385ab807/aiohttp_session-2.12.1.tar.gz", hash = "sha256:15e6e0288e9bcccd4b1d0c28aae9c20e19a252b12d0cb682223ca9c83180e899", size = 92775 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/81/a9ff9032bbe7632fce8812487efe32cee3c76bc0b3221561cd5b6954d876/aiohttp_session-2.12.1-py3-none-any.whl", hash = "sha256:654df46c3c9b73294312795f558c3bca4a85bfd3b01a8b744d984ae3958dce5f", size = 12464 }, +] + +[[package]] +name = "aiormq" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/79/5397756a8782bf3d0dce392b48260c3ec81010f16bef8441ff03505dccb4/aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a", size = 30528 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/be/1a613ae1564426f86650ff58c351902895aa969f7e537e74bfd568f5c8bf/aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017", size = 31174 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, +] + +[[package]] +name = "alembic" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/57/e314c31b261d1e8a5a5f1908065b4ff98270a778ce7579bd4254477209a7/alembic-1.15.2.tar.gz", hash = "sha256:1c72391bbdeffccfe317eefba686cb9a3c078005478885413b95c3b26c57a8a7", size = 1925573 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/18/d89a443ed1ab9bcda16264716f809c663866d4ca8de218aa78fd50b38ead/alembic-1.15.2-py3-none-any.whl", hash = "sha256:2e76bd916d547f6900ec4bb5a90aeac1485d2c92536923d0b138c02b126edc53", size = 231911 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "apispec" +version = "6.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/38/62499ad75cf085f5268458c09ae97007082ed85aec1a9cd9e38f7685fbb0/apispec-6.8.1.tar.gz", hash = "sha256:f4916cbb7be156963b18f5929a0e42bd2349135834b680a81b12432bcfaa9a39", size = 77050 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/03/74947557f3b297cbf3dfc4689079aaf86aae02da69fc0c6a813fa5521556/apispec-6.8.1-py3-none-any.whl", hash = "sha256:eacba00df745efc9adb2a45cf992300e87938582077e101fb26b78ecf4320beb", size = 30461 }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162 }, + { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025 }, + { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243 }, + { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059 }, + { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596 }, + { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632 }, + { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186 }, + { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "44.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022 }, + { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 }, + { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 }, + { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 }, + { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 }, + { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 }, + { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 }, + { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 }, + { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 }, + { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882 }, + { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989 }, + { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714 }, + { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 }, + { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 }, + { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 }, + { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 }, + { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 }, + { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 }, + { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 }, + { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 }, + { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371 }, + { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "frozenlist" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 }, + { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 }, + { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 }, + { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 }, + { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 }, + { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 }, + { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 }, + { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 }, + { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 }, + { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 }, + { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 }, + { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 }, + { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 }, + { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 }, + { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 }, + { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, +] + +[[package]] +name = "identify" +version = "2.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf", size = 99249 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "jeopardybot" +version = "0.0.1" +source = { virtual = "." } +dependencies = [ + { name = "aio-pika" }, + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "aiohttp-apispec" }, + { name = "aiohttp-cors" }, + { name = "aiohttp-session" }, + { name = "aiosignal" }, + { name = "alembic" }, + { name = "apispec" }, + { name = "asyncpg" }, + { name = "attrs" }, + { name = "cffi" }, + { name = "cryptography" }, + { name = "frozenlist" }, + { name = "greenlet" }, + { name = "idna" }, + { name = "iniconfig" }, + { name = "multidict" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pycparser" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, + { name = "webargs" }, + { name = "yarl" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-aiohttp" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aio-pika", specifier = "==9.5.5" }, + { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "aiohttp", specifier = "==3.11.16" }, + { name = "aiohttp-apispec", specifier = "==3.0.0b2" }, + { name = "aiohttp-cors", specifier = "==0.8.1" }, + { name = "aiohttp-session", specifier = "==2.12.1" }, + { name = "aiosignal", specifier = "==1.3.2" }, + { name = "alembic", specifier = "==1.15.2" }, + { name = "apispec", specifier = "==6.8.1" }, + { name = "asyncpg", specifier = "==0.30.0" }, + { name = "attrs", specifier = "==25.3.0" }, + { name = "cffi", specifier = "==1.17.1" }, + { name = "cryptography", specifier = "==44.0.1" }, + { name = "frozenlist", specifier = "==1.5.0" }, + { name = "greenlet", specifier = "==3.1.1" }, + { name = "idna", specifier = "==3.10" }, + { name = "iniconfig", specifier = "==2.1.0" }, + { name = "multidict", specifier = "==6.4.3" }, + { name = "packaging", specifier = "==24.2" }, + { name = "pluggy", specifier = "==1.5.0" }, + { name = "pycparser", specifier = "==2.22" }, + { name = "pydantic", specifier = "==2.11.3" }, + { name = "pydantic-settings", specifier = "==2.8.1" }, + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "sqlalchemy", specifier = "==2.0.40" }, + { name = "typing-extensions", specifier = "==4.13.2" }, + { name = "webargs", specifier = "==8.6.0" }, + { name = "yarl", specifier = "==1.19.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = "==1.15.0" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pytest", specifier = "==8.3.5" }, + { name = "pytest-aiohttp", specifier = "==1.1.0" }, + { name = "pytest-asyncio", specifier = "==0.26.0" }, + { name = "ruff", specifier = "==0.11.5" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, +] + +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, +] + +[[package]] +name = "marshmallow" +version = "3.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878 }, +] + +[[package]] +name = "multidict" +version = "6.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/2c/e367dfb4c6538614a0c9453e510d75d66099edf1c4e69da1b5ce691a1931/multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", size = 89372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/bb/3abdaf8fe40e9226ce8a2ba5ecf332461f7beec478a455d6587159f1bf92/multidict-6.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676", size = 64019 }, + { url = "https://files.pythonhosted.org/packages/7e/b5/1b2e8de8217d2e89db156625aa0fe4a6faad98972bfe07a7b8c10ef5dd6b/multidict-6.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1", size = 37925 }, + { url = "https://files.pythonhosted.org/packages/b4/e2/3ca91c112644a395c8eae017144c907d173ea910c913ff8b62549dcf0bbf/multidict-6.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a", size = 37008 }, + { url = "https://files.pythonhosted.org/packages/60/23/79bc78146c7ac8d1ac766b2770ca2e07c2816058b8a3d5da6caed8148637/multidict-6.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054", size = 224374 }, + { url = "https://files.pythonhosted.org/packages/86/35/77950ed9ebd09136003a85c1926ba42001ca5be14feb49710e4334ee199b/multidict-6.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc", size = 230869 }, + { url = "https://files.pythonhosted.org/packages/49/97/2a33c6e7d90bc116c636c14b2abab93d6521c0c052d24bfcc231cbf7f0e7/multidict-6.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07", size = 231949 }, + { url = "https://files.pythonhosted.org/packages/56/ce/e9b5d9fcf854f61d6686ada7ff64893a7a5523b2a07da6f1265eaaea5151/multidict-6.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde", size = 231032 }, + { url = "https://files.pythonhosted.org/packages/f0/ac/7ced59dcdfeddd03e601edb05adff0c66d81ed4a5160c443e44f2379eef0/multidict-6.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c", size = 223517 }, + { url = "https://files.pythonhosted.org/packages/db/e6/325ed9055ae4e085315193a1b58bdb4d7fc38ffcc1f4975cfca97d015e17/multidict-6.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae", size = 216291 }, + { url = "https://files.pythonhosted.org/packages/fa/84/eeee6d477dd9dcb7691c3bb9d08df56017f5dd15c730bcc9383dcf201cf4/multidict-6.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3", size = 228982 }, + { url = "https://files.pythonhosted.org/packages/82/94/4d1f3e74e7acf8b0c85db350e012dcc61701cd6668bc2440bb1ecb423c90/multidict-6.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507", size = 226823 }, + { url = "https://files.pythonhosted.org/packages/09/f0/1e54b95bda7cd01080e5732f9abb7b76ab5cc795b66605877caeb2197476/multidict-6.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427", size = 222714 }, + { url = "https://files.pythonhosted.org/packages/e7/a2/f6cbca875195bd65a3e53b37ab46486f3cc125bdeab20eefe5042afa31fb/multidict-6.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731", size = 233739 }, + { url = "https://files.pythonhosted.org/packages/79/68/9891f4d2b8569554723ddd6154375295f789dc65809826c6fb96a06314fd/multidict-6.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713", size = 230809 }, + { url = "https://files.pythonhosted.org/packages/e6/72/a7be29ba1e87e4fc5ceb44dabc7940b8005fd2436a332a23547709315f70/multidict-6.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a", size = 226934 }, + { url = "https://files.pythonhosted.org/packages/12/c1/259386a9ad6840ff7afc686da96808b503d152ac4feb3a96c651dc4f5abf/multidict-6.4.3-cp312-cp312-win32.whl", hash = "sha256:8eac0c49df91b88bf91f818e0a24c1c46f3622978e2c27035bfdca98e0e18124", size = 35242 }, + { url = "https://files.pythonhosted.org/packages/06/24/c8fdff4f924d37225dc0c56a28b1dca10728fc2233065fafeb27b4b125be/multidict-6.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:11990b5c757d956cd1db7cb140be50a63216af32cd6506329c2c59d732d802db", size = 38635 }, + { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400 }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "propcache" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430 }, + { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637 }, + { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123 }, + { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031 }, + { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100 }, + { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170 }, + { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000 }, + { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262 }, + { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772 }, + { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133 }, + { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741 }, + { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047 }, + { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467 }, + { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022 }, + { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647 }, + { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784 }, + { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +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/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, +] + +[[package]] +name = "ruff" +version = "0.11.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/71/5759b2a6b2279bb77fe15b1435b89473631c2cd6374d45ccdb6b785810be/ruff-0.11.5.tar.gz", hash = "sha256:cae2e2439cb88853e421901ec040a758960b576126dab520fa08e9de431d1bef", size = 3976488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/db/6efda6381778eec7f35875b5cbefd194904832a1153d68d36d6b269d81a8/ruff-0.11.5-py3-none-linux_armv6l.whl", hash = "sha256:2561294e108eb648e50f210671cc56aee590fb6167b594144401532138c66c7b", size = 10103150 }, + { url = "https://files.pythonhosted.org/packages/44/f2/06cd9006077a8db61956768bc200a8e52515bf33a8f9b671ee527bb10d77/ruff-0.11.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac12884b9e005c12d0bd121f56ccf8033e1614f736f766c118ad60780882a077", size = 10898637 }, + { url = "https://files.pythonhosted.org/packages/18/f5/af390a013c56022fe6f72b95c86eb7b2585c89cc25d63882d3bfe411ecf1/ruff-0.11.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfd80a6ec559a5eeb96c33f832418bf0fb96752de0539905cf7b0cc1d31d779", size = 10236012 }, + { url = "https://files.pythonhosted.org/packages/b8/ca/b9bf954cfed165e1a0c24b86305d5c8ea75def256707f2448439ac5e0d8b/ruff-0.11.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0947c0a1afa75dcb5db4b34b070ec2bccee869d40e6cc8ab25aca11a7d527794", size = 10415338 }, + { url = "https://files.pythonhosted.org/packages/d9/4d/2522dde4e790f1b59885283f8786ab0046958dfd39959c81acc75d347467/ruff-0.11.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad871ff74b5ec9caa66cb725b85d4ef89b53f8170f47c3406e32ef040400b038", size = 9965277 }, + { url = "https://files.pythonhosted.org/packages/e5/7a/749f56f150eef71ce2f626a2f6988446c620af2f9ba2a7804295ca450397/ruff-0.11.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6cf918390cfe46d240732d4d72fa6e18e528ca1f60e318a10835cf2fa3dc19f", size = 11541614 }, + { url = "https://files.pythonhosted.org/packages/89/b2/7d9b8435222485b6aac627d9c29793ba89be40b5de11584ca604b829e960/ruff-0.11.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56145ee1478582f61c08f21076dc59153310d606ad663acc00ea3ab5b2125f82", size = 12198873 }, + { url = "https://files.pythonhosted.org/packages/00/e0/a1a69ef5ffb5c5f9c31554b27e030a9c468fc6f57055886d27d316dfbabd/ruff-0.11.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5f66f8f1e8c9fc594cbd66fbc5f246a8d91f916cb9667e80208663ec3728304", size = 11670190 }, + { url = "https://files.pythonhosted.org/packages/05/61/c1c16df6e92975072c07f8b20dad35cd858e8462b8865bc856fe5d6ccb63/ruff-0.11.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80b4df4d335a80315ab9afc81ed1cff62be112bd165e162b5eed8ac55bfc8470", size = 13902301 }, + { url = "https://files.pythonhosted.org/packages/79/89/0af10c8af4363304fd8cb833bd407a2850c760b71edf742c18d5a87bb3ad/ruff-0.11.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3068befab73620b8a0cc2431bd46b3cd619bc17d6f7695a3e1bb166b652c382a", size = 11350132 }, + { url = "https://files.pythonhosted.org/packages/b9/e1/ecb4c687cbf15164dd00e38cf62cbab238cad05dd8b6b0fc68b0c2785e15/ruff-0.11.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5da2e710a9641828e09aa98b92c9ebbc60518fdf3921241326ca3e8f8e55b8b", size = 10312937 }, + { url = "https://files.pythonhosted.org/packages/cf/4f/0e53fe5e500b65934500949361e3cd290c5ba60f0324ed59d15f46479c06/ruff-0.11.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ef39f19cb8ec98cbc762344921e216f3857a06c47412030374fffd413fb8fd3a", size = 9936683 }, + { url = "https://files.pythonhosted.org/packages/04/a8/8183c4da6d35794ae7f76f96261ef5960853cd3f899c2671961f97a27d8e/ruff-0.11.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2a7cedf47244f431fd11aa5a7e2806dda2e0c365873bda7834e8f7d785ae159", size = 10950217 }, + { url = "https://files.pythonhosted.org/packages/26/88/9b85a5a8af21e46a0639b107fcf9bfc31da4f1d263f2fc7fbe7199b47f0a/ruff-0.11.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:81be52e7519f3d1a0beadcf8e974715b2dfc808ae8ec729ecfc79bddf8dbb783", size = 11404521 }, + { url = "https://files.pythonhosted.org/packages/fc/52/047f35d3b20fd1ae9ccfe28791ef0f3ca0ef0b3e6c1a58badd97d450131b/ruff-0.11.5-py3-none-win32.whl", hash = "sha256:e268da7b40f56e3eca571508a7e567e794f9bfcc0f412c4b607931d3af9c4afe", size = 10320697 }, + { url = "https://files.pythonhosted.org/packages/b9/fe/00c78010e3332a6e92762424cf4c1919065707e962232797d0b57fd8267e/ruff-0.11.5-py3-none-win_amd64.whl", hash = "sha256:6c6dc38af3cfe2863213ea25b6dc616d679205732dc0fb673356c2d69608f800", size = 11378665 }, + { url = "https://files.pythonhosted.org/packages/43/7c/c83fe5cbb70ff017612ff36654edfebec4b1ef79b558b8e5fd933bab836b/ruff-0.11.5-py3-none-win_arm64.whl", hash = "sha256:67e241b4314f4eacf14a601d586026a962f4002a475aa702c69980a38087aa4e", size = 10460287 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/06/552c1f92e880b57d8b92ce6619bd569b25cead492389b1d84904b55989d8/sqlalchemy-2.0.40-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d", size = 2112620 }, + { url = "https://files.pythonhosted.org/packages/01/72/a5bc6e76c34cebc071f758161dbe1453de8815ae6e662393910d3be6d70d/sqlalchemy-2.0.40-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a", size = 2103004 }, + { url = "https://files.pythonhosted.org/packages/bf/fd/0e96c8e6767618ed1a06e4d7a167fe13734c2f8113c4cb704443e6783038/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d", size = 3252440 }, + { url = "https://files.pythonhosted.org/packages/cd/6a/eb82e45b15a64266a2917a6833b51a334ea3c1991728fd905bfccbf5cf63/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716", size = 3263277 }, + { url = "https://files.pythonhosted.org/packages/45/97/ebe41ab4530f50af99e3995ebd4e0204bf1b0dc0930f32250dde19c389fe/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2", size = 3198591 }, + { url = "https://files.pythonhosted.org/packages/e6/1c/a569c1b2b2f5ac20ba6846a1321a2bf52e9a4061001f282bf1c5528dcd69/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191", size = 3225199 }, + { url = "https://files.pythonhosted.org/packages/8f/91/87cc71a6b10065ca0209d19a4bb575378abda6085e72fa0b61ffb2201b84/sqlalchemy-2.0.40-cp312-cp312-win32.whl", hash = "sha256:650490653b110905c10adac69408380688cefc1f536a137d0d69aca1069dc1d1", size = 2082959 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/14c511cda174aa1ad9b0e42b64ff5a71db35d08b0d80dc044dae958921e5/sqlalchemy-2.0.40-cp312-cp312-win_amd64.whl", hash = "sha256:2be94d75ee06548d2fc591a3513422b873490efb124048f50556369a834853b0", size = 2108526 }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "virtualenv" +version = "20.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 }, +] + +[[package]] +name = "webargs" +version = "8.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/51/e9ee5d8315864adf65e92f858f826514538e30db542d4782dd94c2418464/webargs-8.6.0.tar.gz", hash = "sha256:b8d098ab92bd74c659eca705afa31d681475f218cb15c1e57271fa2103c0547a", size = 96610 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/bb/b9b77adeecffd7b41615a7ebd607ac28bd9e09f357d31ce68073b77f0f30/webargs-8.6.0-py3-none-any.whl", hash = "sha256:83da4d7105643d0a50499b06d98a6ade1a330ce66d039eaa51f715172c704aba", size = 31831 }, +] + +[[package]] +name = "yarl" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/4d/8a8f57caccce49573e567744926f88c6ab3ca0b47a257806d1cf88584c5f/yarl-1.19.0.tar.gz", hash = "sha256:01e02bb80ae0dbed44273c304095295106e1d9470460e773268a27d11e594892", size = 184396 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/70/44ef8f69d61cb5123167a4dda87f6c739a833fbdb2ed52960b4e8409d65c/yarl-1.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b687c334da3ff8eab848c9620c47a253d005e78335e9ce0d6868ed7e8fd170b", size = 146855 }, + { url = "https://files.pythonhosted.org/packages/c3/94/38c14d6c8217cc818647689f2dd647b976ced8fea08d0ac84e3c8168252b/yarl-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b0fe766febcf523a2930b819c87bb92407ae1368662c1bc267234e79b20ff894", size = 97523 }, + { url = "https://files.pythonhosted.org/packages/35/a5/43a613586a6255105c4655a911c307ef3420e49e540d6ae2c5829863fb25/yarl-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:742ceffd3c7beeb2b20d47cdb92c513eef83c9ef88c46829f88d5b06be6734ee", size = 95540 }, + { url = "https://files.pythonhosted.org/packages/d4/60/ed26049f4a8b06ebfa6d5f3cb6a51b152fd57081aa818b6497474f65a631/yarl-1.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af682a1e97437382ee0791eacbf540318bd487a942e068e7e0a6c571fadbbd3", size = 344386 }, + { url = "https://files.pythonhosted.org/packages/49/a6/b84899cab411f49af5986cfb44b514040788d81c8084f5811e6a7c0f1ce6/yarl-1.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63702f1a098d0eaaea755e9c9d63172be1acb9e2d4aeb28b187092bcc9ca2d17", size = 338889 }, + { url = "https://files.pythonhosted.org/packages/cc/ce/0704f7166a781b1f81bdd45c4f49eadbae0230ebd35b9ec7cd7769d3a6ff/yarl-1.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3560dcba3c71ae7382975dc1e912ee76e50b4cd7c34b454ed620d55464f11876", size = 353107 }, + { url = "https://files.pythonhosted.org/packages/75/e5/0ecd6f2a9cc4264c16d8dfb0d3d71ba8d03cb58f3bcd42b1df4358331189/yarl-1.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68972df6a0cc47c8abaf77525a76ee5c5f6ea9bbdb79b9565b3234ded3c5e675", size = 353128 }, + { url = "https://files.pythonhosted.org/packages/ad/c7/cd0fd1de581f1c2e8f996e704c9fd979e00106f18eebd91b0173cf1a13c6/yarl-1.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5684e7ff93ea74e47542232bd132f608df4d449f8968fde6b05aaf9e08a140f9", size = 349107 }, + { url = "https://files.pythonhosted.org/packages/e6/34/ba3e5a20bd1d6a09034fc7985aaf1309976f2a7a5aefd093c9e56f6e1e0c/yarl-1.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8182ad422bfacdebd4759ce3adc6055c0c79d4740aea1104e05652a81cd868c6", size = 335144 }, + { url = "https://files.pythonhosted.org/packages/1e/98/d9b7beb932fade015906efe0980aa7d522b8f93cf5ebf1082e74faa314b7/yarl-1.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aee5b90a5a9b71ac57400a7bdd0feaa27c51e8f961decc8d412e720a004a1791", size = 360795 }, + { url = "https://files.pythonhosted.org/packages/9a/11/70b8770039cc54af5948970591517a1e1d093df3f04f328c655c9a0fefb7/yarl-1.19.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8c0b2371858d5a814b08542d5d548adb03ff2d7ab32f23160e54e92250961a72", size = 360140 }, + { url = "https://files.pythonhosted.org/packages/d4/67/708e3e36fafc4d9d96b4eecc6c8b9f37c8ad50df8a16c7a1d5ba9df53050/yarl-1.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd430c2b7df4ae92498da09e9b12cad5bdbb140d22d138f9e507de1aa3edfea3", size = 364431 }, + { url = "https://files.pythonhosted.org/packages/c3/8b/937fbbcc895553a7e16fcd86ae4e0724c6ac9468237ad8e7c29cc3b1c9d9/yarl-1.19.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a93208282c0ccdf73065fd76c6c129bd428dba5ff65d338ae7d2ab27169861a0", size = 373832 }, + { url = "https://files.pythonhosted.org/packages/f8/ca/288ddc2230c9b6647fe907504f1119adb41252ac533eb564d3fc73511215/yarl-1.19.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b8179280cdeb4c36eb18d6534a328f9d40da60d2b96ac4a295c5f93e2799e9d9", size = 378122 }, + { url = "https://files.pythonhosted.org/packages/4f/5a/79e1ef31d14968fbfc0ecec70a6683b574890d9c7550c376dd6d40de7754/yarl-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eda3c2b42dc0c389b7cfda2c4df81c12eeb552019e0de28bde8f913fc3d1fcf3", size = 375178 }, + { url = "https://files.pythonhosted.org/packages/95/38/9b0e56bf14026c3f550ad6425679f6d1a2f4821d70767f39d6f4c56a0820/yarl-1.19.0-cp312-cp312-win32.whl", hash = "sha256:57f3fed859af367b9ca316ecc05ce79ce327d6466342734305aa5cc380e4d8be", size = 86172 }, + { url = "https://files.pythonhosted.org/packages/b3/96/5c2f3987c4bb4e5cdebea3caf99a45946b13a9516f849c02222203d99860/yarl-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:5507c1f7dd3d41251b67eecba331c8b2157cfd324849879bebf74676ce76aff7", size = 92617 }, + { url = "https://files.pythonhosted.org/packages/a4/06/ae25a353e8f032322df6f30d6bb1fc329773ee48e1a80a2196ccb8d1206b/yarl-1.19.0-py3-none-any.whl", hash = "sha256:a727101eb27f66727576630d02985d8a065d09cd0b5fcbe38a5793f71b2a97ef", size = 45990 }, +]