diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8e2929 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.env +.git +.gitignore +.pytest_cache +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b03bae6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,122 @@ +name: Deploy to Server + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + REPOSITORY_NAME: NetVault + IMAGE_NAME: topnik073/netvault + +jobs: + prepare: + name: Extract version from tag + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract-version.outputs.version }} + image_tag: ${{ steps.extract-version.outputs.image_tag }} + steps: + - name: Extract version from tag + id: extract-version + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "image_tag=$TAG_NAME" >> $GITHUB_OUTPUT + echo "Extracted version: $VERSION" + echo "Image tag: $TAG_NAME" + + build: + name: Build & Push Docker image + runs-on: ubuntu-latest + needs: prepare + environment: prod + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + build-args: | + APP_VERSION=${{ needs.prepare.outputs.version }} + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.image_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + needs: [prepare, build] + environment: prod + + steps: + - name: Checkout only docker-compose file + uses: actions/checkout@v4 + with: + sparse-checkout: | + docker-compose.prod.yml + sparse-checkout-cone-mode: false + + - name: Upload .env to server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script_stop: true + script: | + echo "${{ vars.ENV_FILE }}" | grep -v "DRONE_SSH" > /root/${{ env.REPOSITORY_NAME }}/.env + echo ".env uploaded to server" + + - name: Copy docker-compose file to server + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + source: "docker-compose.prod.yml" + target: "/root/${{ env.REPOSITORY_NAME }}/" + + - name: Deploy on remote server + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SERVER_SSH_KEY }} + script: | + echo "Starting deployment with version: ${{ needs.prepare.outputs.version }}" + echo "Image tag: ${{ needs.prepare.outputs.image_tag }}" + + docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.SERVER_GHCR_TOKEN }} + + cd /root/${{ env.REPOSITORY_NAME }} + + echo "Pulling application image..." + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.image_tag }} + + IMAGE_TAG=${{ needs.prepare.outputs.image_tag }} docker compose -f docker-compose.prod.yml pull + IMAGE_TAG=${{ needs.prepare.outputs.image_tag }} docker compose -f docker-compose.prod.yml up -d --remove-orphans + + echo "Deployment complete" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b790e0c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint with Ruff + +on: + push: + branches: + - '**' + +jobs: + ruff-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Ruff and Just + run: | + pip install ruff + curl -LSfs https://just.systems/install.sh | bash -s -- --to /usr/local/bin + + - name: Run lint via Just + run: just lint diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..24d82fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.13-slim AS base + +ENV PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DEFAULT_TIMEOUT=100 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app_dir/src +RUN apt-get update && \ + apt-get install --yes --no-install-recommends netcat-openbsd && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app_dir + +FROM base AS builder + +RUN pip install --upgrade "uv>=0.6,<1.0" && rm -rf /root/.cache/* +ADD pyproject.toml uv.lock ./ +RUN uv sync --locked --no-install-project --verbose --no-progress + +FROM base AS final + +ARG APP_VERSION=unknown +ENV APP_VERSION=${APP_VERSION} + +RUN pip install --upgrade "uv>=0.6,<1.0" +COPY --from=builder /app_dir/.venv ./.venv +COPY src/ ./src +COPY alembic.ini ./ +COPY migrations/ ./migrations/ + +# Run migrations and start the application +CMD while ! nc -z db 5432; do sleep 0.1; done && \ + ./.venv/bin/alembic upgrade head && \ + ./.venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/Justfile b/Justfile index 1d5d04c..7c8d52e 100755 --- a/Justfile +++ b/Justfile @@ -28,16 +28,16 @@ sync: # Automatically format code [group('linters')] ruff-format: - uv run ruff check --fix --unsafe-fixes {{ SOURCE_DIR }} - uv run ruff format . + python -m ruff check --fix --unsafe-fixes {{ SOURCE_DIR }} + python -m ruff format . # Lint code using Ruff [group('linters')] ruff-check: - uv run ruff check {{ SOURCE_DIR }} + python -m ruff check {{ SOURCE_DIR }} -run-server: - uv run python -m src.main server - -run-cli: - uv run python -m src.main interactive +# --- Building --- +# Build local +[group('building')] +build-local: + docker compose -f docker-compose.local.yml up -d --build diff --git a/README.md b/README.md index d4fa54c..2820127 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,81 @@ # NetVault -Асинхронное файловое хранилище на основе TCP протокола с авторизацией пользователей. +Cloud file storage with FastAPI backend and MinIO object storage. ---- +## Description -## Описание +API application for file storage and management with user authentication and bucket-based organization. -Проект представляет собой клиент-серверное приложение для хранения и управления файлами пользователей. Сервер обрабатывает множественные подключения асинхронно, каждый пользователь имеет изолированное хранилище файлов. +## Features -## Возможности +- User authentication with JWT tokens +- Bucket management with permission system (read, write, admin) +- Folder hierarchy +- File upload (simple and multipart) +- File operations (rename, move, delete) +- Public links for file sharing -- 🔐 Авторизация пользователей с хешированием паролей (bcrypt) -- 📁 Управление файлами: загрузка, скачивание, удаление, просмотр списка -- 📂 Поддержка подпапок -- 🔄 Асинхронная обработка множественных клиентов -- 📊 Прогресс-бар для больших файлов -- 🛡️ Валидация путей и защита от path traversal -- 📝 Логирование операций - -## Установка - -### Требования +## Requirements - Python >= 3.13 -- uv (рекомендуется) или pip +- PostgreSQL +- Redis +- MinIO (or compatible S3 storage) -### Установка зависимостей +## Installation ```bash -# С использованием uv uv sync - -# Или с использованием pip -pip install -e . ``` -## Использование +## Configuration -### Запуск сервера +Create `.env` file: -```bash -python -m src.main server ``` - -Сервер запустится на `localhost:8000` (по умолчанию). - -### Использование CLI клиента - -#### Обычные команды - -```bash -# Регистрация нового пользователя -python -m src.main register --login username --password password - -# Авторизация -python -m src.main login --login username --password password - -# Список файлов (можно передать --login и --password для автоматической авторизации) -python -m src.main list [path] [--login username] [--password password] - -# Загрузка файла на сервер (можно передать --login и --password для автоматической авторизации) -python -m src.main put local_file.txt remote_file.txt [--login username] [--password password] - -# Скачивание файла с сервера (можно передать --login и --password для автоматической авторизации) -python -m src.main get remote_file.txt local_file.txt [--login username] [--password password] - -# Удаление файла или папки (можно передать --login и --password для автоматической авторизации) -python -m src.main delete path/to/file [--login username] [--password password] +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASS=your_password +DB_NAME=netvault + +REDIS_HOST=localhost +REDIS_PORT=6379 + +MINIO_ENDPOINT=localhost:9000 +MINIO_EXTERNAL_ENDPOINT=192.168.1.35:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin + +YC_POSTBOX_ACCESS_KEY=your_access_key +YC_POSTBOX_SECRET_KEY=your_secret_key +YC_POSTBOX_REGION=your_region +YC_POSTBOX_ENDPOINT=https://postbox.cloud.yandex.net +MAIL_FROM=no-reply@netvault.ru + +JWT_SECRET=your_secret_key ``` -#### Интерактивный режим - -Для работы с сохранением соединения между командами: +## Running ```bash -python -m src.main interactive -``` - -В интерактивном режиме доступны команды: -- `login <логин> <пароль>` - авторизация -- `register <логин> <пароль>` - регистрация -- `list [path]` - список файлов -- `get ` - скачать файл -- `put ` - загрузить файл -- `delete ` - удалить файл/папку -- `help` - справка -- `exit` - выход - -## Архитектура - -### Структура проекта - -``` -src/ -├── client/ # Клиентская часть -│ ├── cli.py # CLI интерфейс (click) -│ ├── client.py # TCP клиент -│ └── protocol.py # Протокол обмена данными -├── server/ # Серверная часть -│ ├── __init__.py # Асинхронный TCP сервер -│ ├── server.py # TCP сервер -│ ├── auth.py # Авторизация пользователей -│ ├── storage.py # Файловое хранилище -│ └── protocol.py # Протокол обмена данными -└── utils/ # Утилиты - ├── config.py # Конфигурация - ├── constants.py # Константы - ├── exceptions.py # Кастомные исключения - ├── logger.py # Логирование - ├── security.py # Безопасность (хеширование) - └── types.py # Типы данных +uv run python -m src ``` -### Протокол обмена данными - -- **JSON команды**: Команды отправляются в формате JSON с полем `command` -- **Бинарные данные**: Файлы передаются бинарно после JSON метаданных -- **Формат**: Длина сообщения (4 байта) + данные - -### Хранилище - -- Пользователи хранятся в `users.json` (UUID, логин, хеш пароля) -- Файлы пользователей в `storage/{user_uuid}/` -- Каждый пользователь имеет изолированное хранилище +## API Endpoints -## Конфигурация +You can find it after server startup. There will be the route in terminal for Swagger documentation (which provided by FastAPI automatically) -Настройки можно изменить через переменные окружения или в `src/utils/config.py`: +## Development -- `HOST` - адрес сервера (по умолчанию: localhost) -- `PORT` - порт сервера (по умолчанию: 8000) -- `CHUNK_SIZE` - размер чанка для передачи файлов (по умолчанию: 65536 байт) -- `MAX_FILE_SIZE` - максимальный размер файла (по умолчанию: 1 GB) -- `MIN_PASSWORD_LENGTH` - минимальная длина пароля (по умолчанию: 6) -- `MAX_LOGIN_LENGTH` - максимальная длина логина (по умолчанию: 50) -- `MAX_PATH_LENGTH` - максимальная длина пути (по умолчанию: 4096) - -## Примеры использования - -### Авторизация и работа с файлами - -```bash -# 1. Запустить сервер -python -m src.main server - -# 2. В другом терминале - зарегистрироваться -python -m src.main register --login user1 --password pass123 - -# 3. Загрузить файл с авторизацией в команде -python -m src.main put ~/Documents/file.txt documents/file.txt --login user1 --password pass123 - -# 4. Посмотреть список файлов с авторизацией -python -m src.main list --login user1 --password pass123 - -# 5. Скачать файл с авторизацией -python -m src.main get documents/file.txt ~/Downloads/file.txt --login user1 --password pass123 - -# 6. Удалить файл с авторизацией -python -m src.main delete documents/file.txt --login user1 --password pass123 -``` - -### Интерактивный режим +Run linters: ```bash -python -m src.main interactive - -> login user1 pass123 -Авторизация успешна -> list -📄 file.txt (1024 байт) -📁 documents -> put ~/test.txt test.txt -Загрузка файла (512 байт)... -Файл загружен: test.txt -> list -📄 file.txt (1024 байт) -📄 test.txt (512 байт) -📁 documents -> exit +just lint ``` -## Безопасность - -- Пароли хранятся в виде bcrypt хешей -- Защита от path traversal атак -- Валидация входных данных -- Ограничение размера файлов -- Изоляция файлов пользователей +Run migrations: -## Логирование - -Логи сохраняются в: -- Консоль -- Файл `logs/app.log` (при log_to_file в настройках) - -## Разработка - -### Запуск в режиме разработки - -```bash -# Установить зависимости -uv sync - -# Запустить сервер -python -m src.main server - -# В другом терминале запустить клиент -python -m src.main interactive -``` - -### Если имеется установленный [Just](https://github.com/casey/just) ```bash -# Запуск сервера -just run-server - -# Запуск клиента в другом терминале (сразу в интерактивном режиме) -just run-cli -``` +alembic upgrade head +``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..30837e2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,141 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = postgresql://%(DB_USER)s:%(DB_PASS)s@%(DB_HOST)s:%(DB_PORT)s/%(DB_NAME)s + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +;hooks = black +;black.type = console_scripts +;black.entrypoint = black +;black.options = -l 100 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +hooks = ruff +ruff.type = exec +ruff.executable = %(here)s/.venv/bin/ruff +ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[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/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6cf17c2 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,87 @@ +import os +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import engine_from_config, pool + +from alembic import context +from src.database.models import BaseORM + +load_dotenv() + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +section = config.config_ini_section +config.set_section_option(section, "DB_USER", os.getenv("DB_USER")) +config.set_section_option(section, "DB_PASS", os.getenv("DB_PASS")) +config.set_section_option(section, "DB_HOST", os.getenv("DB_HOST")) +config.set_section_option(section, "DB_PORT", os.getenv("DB_PORT")) +config.set_section_option(section, "DB_NAME", os.getenv("DB_NAME")) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = BaseORM.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + 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 run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/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, Sequence[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/alembic/versions/2026_03_08_1951-4f9e2a95d3f0_init.py b/alembic/versions/2026_03_08_1951-4f9e2a95d3f0_init.py new file mode 100644 index 0000000..399ae91 --- /dev/null +++ b/alembic/versions/2026_03_08_1951-4f9e2a95d3f0_init.py @@ -0,0 +1,153 @@ +"""init + +Revision ID: 4f9e2a95d3f0 +Revises: +Create Date: 2026-03-08 19:51:38.269536 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '4f9e2a95d3f0' +down_revision: str | Sequence[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('users', + sa.Column('email', sa.String(), nullable=False), + sa.Column('password_hash', sa.String(), nullable=False), + sa.Column('storage_quota_bytes', sa.BigInteger(), nullable=False), + sa.Column('storage_used_bytes', sa.BigInteger(), nullable=False), + sa.Column('storage_reserved_bytes', sa.BigInteger(), nullable=False), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('buckets', + sa.Column('name', sa.String(), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('minio_bucket_name', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('minio_bucket_name'), + sa.UniqueConstraint('owner_id', 'name', name='uq_bucket_owner_name') + ) + op.create_table('event_log', + sa.Column('user_id', sa.UUID(), nullable=True), + sa.Column('action', sa.String(), nullable=False), + sa.Column('entity_type', sa.String(), nullable=False), + sa.Column('entity_id', sa.UUID(), nullable=True), + sa.Column('metadata_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('bucket_permissions', + sa.Column('bucket_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('permission_type', sa.String(), nullable=False), + sa.Column('granted_by', sa.UUID(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['granted_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('bucket_id', 'user_id', name='uq_bucket_user_permission') + ) + op.create_table('folders', + sa.Column('bucket_id', sa.UUID(), nullable=False), + sa.Column('parent_id', sa.UUID(), nullable=True), + sa.Column('name', sa.String(), nullable=False), + sa.Column('depth', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_id'], ['folders.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('bucket_id', 'parent_id', 'name', name='uq_folder_path') + ) + op.create_table('files', + sa.Column('original_filename', sa.String(), nullable=False), + sa.Column('storage_filename', sa.String(), nullable=False), + sa.Column('bucket_id', sa.UUID(), nullable=False), + sa.Column('folder_id', sa.UUID(), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.Column('file_size_bytes', sa.BigInteger(), nullable=False), + sa.Column('mime_type', sa.String(), nullable=True), + sa.Column('file_hash', sa.String(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ), + sa.ForeignKeyConstraint(['folder_id'], ['folders.id'], ), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('bucket_id', 'storage_filename', name='uq_file_storage_name') + ) + op.create_table('minio_sessions', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('bucket_id', sa.UUID(), nullable=False), + sa.Column('folder_id', sa.UUID(), nullable=True), + sa.Column('operation_type', sa.String(), nullable=False), + sa.Column('minio_session_id', sa.String(), nullable=False), + sa.Column('object_name', sa.String(), nullable=False), + sa.Column('object_size_bytes', sa.BigInteger(), nullable=False), + sa.Column('reserved_bytes', sa.BigInteger(), nullable=False), + sa.Column('total_parts', sa.Integer(), nullable=True), + sa.Column('completed_parts', sa.Integer(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['bucket_id'], ['buckets.id'], ), + sa.ForeignKeyConstraint(['folder_id'], ['folders.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('public_links', + sa.Column('file_id', sa.UUID(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('max_downloads', sa.Integer(), nullable=True), + sa.Column('downloads_count', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['file_id'], ['files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('public_links') + op.drop_table('minio_sessions') + op.drop_table('files') + op.drop_table('folders') + op.drop_table('bucket_permissions') + op.drop_table('event_log') + op.drop_table('buckets') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_03_10_1224-95be5ffd94ba_add_path_to_files.py b/alembic/versions/2026_03_10_1224-95be5ffd94ba_add_path_to_files.py new file mode 100644 index 0000000..f218f4c --- /dev/null +++ b/alembic/versions/2026_03_10_1224-95be5ffd94ba_add_path_to_files.py @@ -0,0 +1,32 @@ +"""Add path to files + +Revision ID: 95be5ffd94ba +Revises: 4f9e2a95d3f0 +Create Date: 2026-03-10 12:24:07.827339 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '95be5ffd94ba' +down_revision: str | Sequence[str] | None = '4f9e2a95d3f0' +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.add_column('files', sa.Column('path', sa.String(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('files', 'path') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_03_10_2043-8ff15344eecb_add_folder_id_to_public_links.py b/alembic/versions/2026_03_10_2043-8ff15344eecb_add_folder_id_to_public_links.py new file mode 100644 index 0000000..fced437 --- /dev/null +++ b/alembic/versions/2026_03_10_2043-8ff15344eecb_add_folder_id_to_public_links.py @@ -0,0 +1,34 @@ +"""Add folder_id to public_links + +Revision ID: 8ff15344eecb +Revises: 95be5ffd94ba +Create Date: 2026-03-10 20:43:26.344689 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8ff15344eecb' +down_revision: str | Sequence[str] | None = '95be5ffd94ba' +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.add_column('public_links', sa.Column('folder_id', sa.UUID(), nullable=False)) + op.create_foreign_key(None, 'public_links', 'folders', ['folder_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'public_links', type_='foreignkey') + op.drop_column('public_links', 'folder_id') + # ### end Alembic commands ### diff --git a/alembic/versions/2026_03_10_2050-73dd4fe22fce_make_file_id_and_folder_id_optional.py b/alembic/versions/2026_03_10_2050-73dd4fe22fce_make_file_id_and_folder_id_optional.py new file mode 100644 index 0000000..4ad6731 --- /dev/null +++ b/alembic/versions/2026_03_10_2050-73dd4fe22fce_make_file_id_and_folder_id_optional.py @@ -0,0 +1,42 @@ +"""Make file_id and folder_id optional + +Revision ID: 73dd4fe22fce +Revises: 8ff15344eecb +Create Date: 2026-03-10 20:50:12.137327 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '73dd4fe22fce' +down_revision: str | Sequence[str] | None = '8ff15344eecb' +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('public_links', 'file_id', + existing_type=sa.UUID(), + nullable=True) + op.alter_column('public_links', 'folder_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('public_links', 'folder_id', + existing_type=sa.UUID(), + nullable=False) + op.alter_column('public_links', 'file_id', + existing_type=sa.UUID(), + nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/2026_03_10_2200-e3cac20653b2_make_folder_id_optional_in_minio_.py b/alembic/versions/2026_03_10_2200-e3cac20653b2_make_folder_id_optional_in_minio_.py new file mode 100644 index 0000000..82fec23 --- /dev/null +++ b/alembic/versions/2026_03_10_2200-e3cac20653b2_make_folder_id_optional_in_minio_.py @@ -0,0 +1,36 @@ +"""Make folder_id optional in minio_sessions + +Revision ID: e3cac20653b2 +Revises: 73dd4fe22fce +Create Date: 2026-03-10 22:00:49.250494 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e3cac20653b2' +down_revision: str | Sequence[str] | None = '73dd4fe22fce' +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('minio_sessions', 'folder_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('minio_sessions', 'folder_id', + existing_type=sa.UUID(), + nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/2026_03_28_2031-aa79ea6388f8_fix_folder_id_nullable.py b/alembic/versions/2026_03_28_2031-aa79ea6388f8_fix_folder_id_nullable.py new file mode 100644 index 0000000..8072bbc --- /dev/null +++ b/alembic/versions/2026_03_28_2031-aa79ea6388f8_fix_folder_id_nullable.py @@ -0,0 +1,36 @@ +"""fix_folder_id_nullable + +Revision ID: aa79ea6388f8 +Revises: e3cac20653b2 +Create Date: 2026-03-28 20:31:42.305778 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'aa79ea6388f8' +down_revision: str | Sequence[str] | None = 'e3cac20653b2' +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('files', 'folder_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('files', 'folder_id', + existing_type=sa.UUID(), + nullable=False) + # ### end Alembic commands ### diff --git a/src/utils/__init__.py b/alembic/versions/__init__.py similarity index 100% rename from src/utils/__init__.py rename to alembic/versions/__init__.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3fcf6eb --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,57 @@ +version: '3.8' + +services: + db: + image: postgres:17.2 + container_name: netvault-db + restart: unless-stopped + environment: + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASS} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + + + redis: + image: redis:7 + container_name: netvault-redis + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + + + minio: + image: minio/minio:latest + container_name: netvault-minio + restart: unless-stopped + environment: + - MINIO_ROOT_USER=${MINIO_ACCESS_KEY} + - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + +volumes: + postgres_data: + redis_data: + minio_data: diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..c3cb52b --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,94 @@ +version: '3.8' + +services: + app: + build: . + container_name: netvault-app + restart: unless-stopped + env_file: + - .env + environment: + - POSTGRES_HOST=db + - REDIS_HOST=redis + - MINIO_ENDPOINT=minio:9000 + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + minio: + condition: service_started + volumes: + - .:/app + - app_logs:/app/logs + networks: + - app_network + + + db: + image: postgres:17.2 + container_name: netvault-db + restart: unless-stopped + environment: + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASS} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 5s + timeout: 5s + retries: 5 + + + redis: + image: redis:7 + container_name: netvault-redis + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app_network + restart: unless-stopped + + + minio: + image: minio/minio:latest + container_name: netvault-minio + restart: unless-stopped + environment: + - MINIO_ROOT_USER=${MINIO_ACCESS_KEY} + - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + networks: + - app_network + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + +volumes: + postgres_data: + redis_data: + minio_data: + app_logs: + +networks: + app_network: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..ce6e58f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + app: + image: ghcr.io/topnik073/netvault:${IMAGE_TAG:-latest} + container_name: netvault-app + restart: unless-stopped + env_file: + - .env + environment: + - POSTGRES_HOST=db + - REDIS_HOST=redis + - MINIO_ENDPOINT=minio:9000 + ports: + - "8000:8000" + depends_on: + - db + - redis + - minio + volumes: + - app_logs:/app/logs + networks: + - app_network + + + db: + image: postgres:17.2 + container_name: netvault-db + restart: unless-stopped + environment: + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=${DB_USER} + - POSTGRES_PASSWORD=${DB_PASS} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app_network + + + redis: + image: redis:7 + container_name: netvault-redis + command: redis-server --requirepass ${REDIS_PASSWORD} + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - app_network + restart: unless-stopped + + + minio: + image: minio/minio:latest + container_name: netvault-minio + restart: unless-stopped + environment: + - MINIO_ROOT_USER=${MINIO_ACCESS_KEY} + - MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + networks: + - app_network + command: server /data --console-address ":9001" + + +volumes: + postgres_data: + redis_data: + minio_data: + app_logs: + +networks: + app_network: + driver: bridge diff --git a/pyproject.toml b/pyproject.toml index 3555003..d8ee736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,87 +5,126 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "bcrypt>=5.0.0", - "click>=8.3.1", - "ruff>=0.14.7", + "aioboto3>=15.5.0", + "alembic>=1.18.4", + "asyncpg>=0.31.0", + "email-validator>=2.3.0", + "fastapi>=0.128.7", + "greenlet>=3.3.1", + "minio>=7.2.20", + "psycopg2-binary>=2.9.11", + "pwdlib[argon2]>=0.3.0", + "pydantic-settings>=2.12.0", + "python-jose>=3.5.0", + "redis>=7.2.0", + "sqlalchemy>=2.0.46", + "structlog>=25.5.0", + "uvicorn>=0.40.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.15.0", + "def-form>=0.2.0", + "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-cov>=7.0.0", +] + +[tool.def-form] +max_def_length = 100 +max_inline_args = 2 +indent_size = 4 +exclude = [ + ".venv/", +] + +[tool.mypy] +python_version = "3.13" +ignore_missing_imports = true +follow_imports = "silent" +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +strict_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +no_implicit_reexport = true +exclude = [ + ".venv/", + "tests/cases/", ] [tool.ruff] target-version = "py313" line-length = 120 exclude = [ - ".venv", -] -format.quote-style = "single" -lint.select = [ - "F", # Pyflakes - # "E/W", # pycodestyle - "C90", # mccabe - # "I", # isort - # "N", # pep8-naming - # "D", # pydocstyle - "UP", # pyupgrade - "YTT", # flake8-2020 - # "ANN", # flake8-annotations - # "ASYNC", # flake8-async - # "TRIO", # flake8-trio - # "S", # flake8-bandit - # "BLE", # flake8-blind-except - # "FBT", # flake8-boolean-trap - "B", # flake8-bugbear - # "A", # flake8-builtins - # "COM", # flake8-commas - # "CPY", # flake8-copyright - "C4", # flake8-comprehensions - # "DTZ", # flake8-datetimez - "T10", # flake8-debugger - # "DJ", # flake8-django - # "EM", # flake8-errmsg - # "EXE", # flake8-executable - # "FA", # flake8-future-annotations - "ISC", # flake8-implicit-str-concat - # "ICN", # flake8-import-conventions - "G", # flake8-logging-format - "INP", # flake8-no-pep420 - # "PIE", # flake8-pie - "T20", # flake8-print - # "PYI", # flake8-pyi - # "PT", # flake8-pytest-style - # "Q", # flake8-quotes - "RSE", # flake8-raise - "RET", # flake8-return - "SLF", # flake8-self - # "SLOT", # flake8-slots - "SIM", # flake8-simplify - "TID", # flake8-tidy-imports - # "TCH", # flake8-type-checking - # "INT", # flake8-gettext - # "ARG", # flake8-unused-arguments - "PTH", # flake8-use-pathlib - # "TD", # flake8-todos - # "FIX", # flake8-fixme - "ERA", # eradicate - # "PD", # pandas-vet - # "PGH", # pygrep-hooks - "PL", # Pylint - # "TRY", # tryceratops - # "FLY", # flynt - # "NPY", # NumPy-specific rules - # "AIR", # Airflow - # "PERF", # Perflint - # "FURB", # refurb - # "LOG", # flake8-logging - "RUF" # Ruff-specific rules + ".venv/", + "tests/cases/", ] lint.flake8-tidy-imports.ban-relative-imports = "all" lint.mccabe.max-complexity = 20 -lint.ignore = [ - "RUF001", # String contains ambiguous characters (false positives for Cyrillic) - "RUF002", # Docstring contains ambiguous characters (false positives for Cyrillic) - "RUF003", # Comment contains ambiguous characters (false positives for Cyrillic) - "PLW0603", # Using global statement (needed for singleton pattern) - "PLW0602", # Using global without assignment (needed for cleanup) - "C901", # Function is too complex (acceptable for interactive CLI) - "PLR0912", # Too many branches (acceptable for command handlers) - "PLR0915", # Too many statements (acceptable for interactive CLI) -] \ No newline at end of file +lint.select = [ + "F", # Pyflakes + # "E/W", # pycodestyle + "C90", # mccabe + # "I", # isort + # "N", # pep8-naming + # "D", # pydocstyle + "UP", # pyupgrade + "YTT", # flake8-2020 + # "ANN", # flake8-annotations + # "ASYNC", # flake8-async + # "TRIO", # flake8-trio + # "S", # flake8-bandit + # "BLE", # flake8-blind-except + # "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + # "A", # flake8-builtins + # "COM", # flake8-commas + # "CPY", # flake8-copyright + "C4", # flake8-comprehensions + # "DTZ", # flake8-datetimez + "T10", # flake8-debugger + # "DJ", # flake8-django + # "EM", # flake8-errmsg + # "EXE", # flake8-executable + # "FA", # flake8-future-annotations + # "ISC", # flake8-implicit-str-concat + # "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + # "PIE", # flake8-pie + "T20", # flake8-print + # "PYI", # flake8-pyi + # "PT", # flake8-pytest-style + # "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + # "SLOT", # flake8-slots + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + # "TCH", # flake8-type-checking + # "INT", # flake8-gettext + # "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + # "TD", # flake8-todos + # "FIX", # flake8-fixme + "ERA", # eradicate + # "PD", # pandas-vet + # "PGH", # pygrep-hooks + "PL", # Pylint + # "TRY", # tryceratops + # "FLY", # flynt + # "NPY", # NumPy-specific rules + # "AIR", # Airflow + # "PERF", # Perflint + # "FURB", # refurb + # "LOG", # flake8-logging + "RUF", # Ruff-specific rules +] + +[tool.ruff.format] +quote-style = "single" +docstring-code-format = true diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..817881b --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,18 @@ +import uvicorn + +from src.app_factory import AppFactory +from src.core.config import config +from src.handlers import ROUTERS +from src.middlewares import MIDDLEWARES + +if __name__ == '__main__': + app = AppFactory( + title=config.APP_NAME, + version=config.APP_VERSION, + debug=config.DEBUG, + lifespan=None, + routers=ROUTERS, + middlewares=MIDDLEWARES, + ) + + uvicorn.run(app.app, host=config.APP_HOST, port=config.APP_PORT, log_level='critical') \ No newline at end of file diff --git a/src/app_factory.py b/src/app_factory.py new file mode 100644 index 0000000..d35b87e --- /dev/null +++ b/src/app_factory.py @@ -0,0 +1,88 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import datetime, UTC +from typing import Any + +from fastapi import FastAPI, APIRouter +from starlette.middleware.base import BaseHTTPMiddleware + +from src.core.config import config +from src.core.logger import get_logger +from src.database.connection import engine +from src.integrations.redis.connection import init_redis_pool, close_redis_pool + +logger = get_logger('app_factory') + +class AppFactory: + def __init__( # noqa: PLR0913 + self, + *, + title: str, + version: str, + debug: bool = False, + lifespan: AsyncGenerator[None, Any] | None = None, + routers: list[APIRouter] | None = None, + middlewares: list[type[BaseHTTPMiddleware]] | None = None, + ): + self._app = FastAPI( + title=title, + version=version, + debug=debug, + lifespan=lifespan or self.lifespan, + ) + self._middlewares = middlewares + self._routers = routers + + self.setup_app() + + @property + def app(self) -> FastAPI: + return self._app + + @property + def routers(self) -> list[APIRouter]: + return self._routers + + @property + def middlewares(self) -> list[type[BaseHTTPMiddleware]]: + return self._middlewares + + @asynccontextmanager + async def lifespan(self, app: FastAPI): + # Startup + logger.info('Initializing application dependencies') + + app.state.redis = await init_redis_pool() + app.state.start_time = datetime.now(UTC) + + + docs_route = f'http://{config.APP_HOST}:{config.APP_PORT}/docs' + logger.info( + f'Application {config.APP_NAME}_{config.APP_VERSION} started successfully. See docs here {docs_route}' # noqa: G004 + ) + yield + + # Shutdown + logger.info('Shutting down application') + + await close_redis_pool(app.state.redis) + await engine.dispose() + + logger.info('Application shutdown complete') + + def setup_app(self): + if self._routers: + self.setup_routers(self._app, self._routers) + + if self._middlewares: + self.setup_middlewares(self._app, self._middlewares) + + @staticmethod + def setup_routers(app: FastAPI, routers: list[APIRouter]) -> None: + for router in routers: + app.include_router(router) + + @staticmethod + def setup_middlewares(app: FastAPI, middlewares: list[type[BaseHTTPMiddleware]]) -> None: + for middleware in middlewares: + app.add_middleware(middleware) \ No newline at end of file diff --git a/src/client/__init__.py b/src/client/__init__.py deleted file mode 100644 index 721cbe8..0000000 --- a/src/client/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from src.client.cli import cli -from src.client.client import FileStorageClient - -__all__ = ['FileStorageClient', 'cli'] diff --git a/src/client/cli.py b/src/client/cli.py deleted file mode 100644 index d37bb8d..0000000 --- a/src/client/cli.py +++ /dev/null @@ -1,282 +0,0 @@ -import click -import asyncio -import atexit -import shlex - -from src.client.client import FileStorageClient -import contextlib - - -_client = None -_loop = None - - -def get_event_loop(): - """Получает или создает event loop""" - global _loop - try: - _loop = asyncio.get_event_loop() - if _loop.is_closed(): - _loop = asyncio.new_event_loop() - asyncio.set_event_loop(_loop) - except RuntimeError: - _loop = asyncio.new_event_loop() - asyncio.set_event_loop(_loop) - return _loop - - -def get_client() -> FileStorageClient: - """Получает глобальный экземпляр клиента""" - global _client - if _client is None: - _client = FileStorageClient() - atexit.register(cleanup_client) - return _client - - -def cleanup_client(): - """Закрывает соединение при выходе из программы""" - global _client, _loop - if _client and _loop and not _loop.is_closed(): - with contextlib.suppress(Exception): - _loop.run_until_complete(_client.disconnect()) - - -def run_async(coro): - """Запускает корутину в глобальном event loop""" - loop = get_event_loop() - if loop.is_running(): - return asyncio.run(coro) - return loop.run_until_complete(coro) - - -def ensure_authenticated(client: FileStorageClient, login: str | None = None, password: str | None = None) -> bool: - """Обеспечивает авторизацию клиента. Если передан login и password, выполняет авторизацию""" - if login and password: - result = run_async(client.login(login, password)) - if not result: - click.echo('Ошибка авторизации', err=True) - return False - return True - if not client.authenticated: - click.echo('Требуется авторизация. Используйте команду login или передайте --login и --password.', err=True) - return False - return True - - -@click.group() -def cli(): - """CLI клиент для файлового хранилища""" - pass - - -@cli.command() -@click.option('--login', prompt='Логин', help='Логин пользователя') -@click.option('--password', prompt='Пароль', hide_input=True, help='Пароль пользователя') -def register(login: str, password: str): - """Регистрирует нового пользователя на сервере""" - client = get_client() - result = run_async(client.register(login, password)) - if result: - click.echo('Регистрация успешна') - else: - click.echo('Ошибка регистрации', err=True) - - -@cli.command() -@click.option('--login', prompt='Логин', help='Логин пользователя') -@click.option('--password', prompt='Пароль', hide_input=True, help='Пароль пользователя') -def login(login: str, password: str): - """Авторизуется на сервере""" - client = get_client() - result = run_async(client.login(login, password)) - if result: - click.echo('Авторизация успешна') - else: - click.echo('Ошибка авторизации', err=True) - - -@cli.command() -@click.argument('path', required=False, default='') -@click.option('--login', help='Логин для авторизации (если не авторизован)') -@click.option('--password', help='Пароль для авторизации (если не авторизован)') -def list(path: str, login: str | None, password: str | None): - """Выводит список файлов и папок""" - client = get_client() - if not ensure_authenticated(client, login, password): - return - - files = run_async(client.list_files(path)) - if files is not None: - if not files: - click.echo('Папка пуста') - else: - for item in files: - item_type = '📁' if item['type'] == 'directory' else '📄' - size = f' ({item["size"]} байт)' if item['type'] == 'file' else '' - click.echo(f'{item_type} {item["name"]}{size}') - - -@cli.command() -@click.argument('remote_path') -@click.argument('local_path') -@click.option('--login', help='Логин для авторизации (если не авторизован)') -@click.option('--password', help='Пароль для авторизации (если не авторизован)') -def get(remote_path: str, local_path: str, login: str | None, password: str | None): - """Скачивает файл с сервера""" - client = get_client() - if not ensure_authenticated(client, login, password): - return - run_async(client.get_file(remote_path, local_path)) - - -@cli.command() -@click.argument('local_path') -@click.argument('remote_path') -@click.option('--login', help='Логин для авторизации (если не авторизован)') -@click.option('--password', help='Пароль для авторизации (если не авторизован)') -def put(local_path: str, remote_path: str, login: str | None, password: str | None): - """Загружает файл на сервер""" - client = get_client() - if not ensure_authenticated(client, login, password): - return - run_async(client.put_file(local_path, remote_path)) - - -@cli.command() -@click.argument('path') -@click.option('--login', help='Логин для авторизации (если не авторизован)') -@click.option('--password', help='Пароль для авторизации (если не авторизован)') -def delete(path: str, login: str | None, password: str | None): - """Удаляет файл или директорию на сервере""" - client = get_client() - if not ensure_authenticated(client, login, password): - return - run_async(client.delete_file(path)) - - -@cli.command() -@click.argument('source_path') -@click.argument('destination_path') -@click.option('--login', help='Логин для авторизации (если не авторизован)') -@click.option('--password', help='Пароль для авторизации (если не авторизован)') -def move(source_path: str, destination_path: str, login: str | None, password: str | None): - """Перемещает или переименовывает файл или директорию на сервере""" - client = get_client() - if not ensure_authenticated(client, login, password): - return - run_async(client.move_file(source_path, destination_path)) - - -@cli.command() -def interactive(): - """Запускает интерактивный режим (соединение сохраняется между командами)""" - click.echo("Интерактивный режим. Введите 'help' для справки, 'exit' для выхода.") - - client = get_client() - - while True: - try: - line = input('> ').strip() - if not line: - continue - - if line.lower() in ['exit', 'quit', 'q']: - click.echo('Выход...') - run_async(client.disconnect()) - break - - parts = shlex.split(line) - if not parts: - continue - - cmd = parts[0] - args = parts[1:] - - MIN_LOGIN_ARGS = 2 - if cmd == 'login': - if len(args) >= MIN_LOGIN_ARGS: - login, password = args[0], args[1] - result = run_async(client.login(login, password)) - click.echo('Авторизация успешна' if result else 'Ошибка авторизации', err=not result) - else: - click.echo('Использование: login <логин> <пароль>', err=True) - - elif cmd == 'register': - if len(args) >= MIN_LOGIN_ARGS: - result = run_async(client.register(args[0], args[1])) - click.echo('Регистрация успешна' if result else 'Ошибка регистрации', err=not result) - else: - click.echo('Использование: register <логин> <пароль>', err=True) - - elif cmd == 'logout': - result = run_async(client.logout()) - click.echo('Выход выполнен' if result else 'Ошибка выхода', err=not result) - - elif cmd == 'list': - path = args[0] if args else '' - files = run_async(client.list_files(path)) - if files is not None: - if not files: - click.echo('Папка пуста') - else: - for item in files: - item_type = '📁 ' if item['type'] == 'directory' else '📄 ' - size = f' ({item["size"]} байт)' if item['type'] == 'file' else '' - click.echo(f'{item_type} {item["name"]}{size}') - - elif cmd == 'get': - if len(args) >= MIN_LOGIN_ARGS: - run_async(client.get_file(args[0], args[1])) - else: - click.echo('Использование: get ', err=True) - - elif cmd == 'put': - if len(args) >= MIN_LOGIN_ARGS: - run_async(client.put_file(args[0], args[1])) - else: - click.echo('Использование: put ', err=True) - - elif cmd == 'delete': - if args: - run_async(client.delete_file(args[0])) - else: - click.echo('Использование: delete ', err=True) - - elif cmd == 'move': - if len(args) >= MIN_LOGIN_ARGS: - run_async(client.move_file(args[0], args[1])) - else: - click.echo('Использование: move ', err=True) - - elif cmd == 'help': - click.echo(""" -Доступные команды: - login <логин> <пароль> - Авторизация - register <логин> <пароль> - Регистрация - logout - Выход из аккаунта - list [path] - Список файлов - get - Скачать файл - put - Загрузить файл - delete - Удалить файл/папку - move - Переместить/переименовать файл/папку - exit - Выход - """) - - else: - click.echo(f"Неизвестная команда: {cmd}. Введите 'help' для справки.", err=True) - - except KeyboardInterrupt: - click.echo('\nВыход...') - run_async(client.disconnect()) - break - except EOFError: - click.echo('\nВыход...') - run_async(client.disconnect()) - break - except Exception as e: - click.echo(f'Ошибка: {e}', err=True) - - -if __name__ == '__main__': - cli() diff --git a/src/client/client.py b/src/client/client.py deleted file mode 100644 index d79064e..0000000 --- a/src/client/client.py +++ /dev/null @@ -1,296 +0,0 @@ -import asyncio -from pathlib import Path - -import click - -from src.client.protocol import ClientProtocol -from src.utils.config import config -from src.utils.exceptions import StorageConnectionError, AuthenticationError, FileError -from src.utils.logger import client_logger - - -class FileStorageClient: - """Клиент для работы с файловым хранилищем""" - - def __init__(self): - self.reader: asyncio.StreamReader | None = None - self.writer: asyncio.StreamWriter | None = None - self.authenticated = False - - async def connect(self) -> bool: - """Подключается к серверу""" - if self.writer and not self.writer.is_closing(): - try: - self.writer.close() - await self.writer.wait_closed() - except Exception: - pass - - try: - self.reader, self.writer = await asyncio.open_connection(config.host, config.port) - self.authenticated = False - client_logger.info(f'Подключено к серверу {config.host}:{config.port}') - return True - except Exception as e: - client_logger.error(f'Ошибка подключения: {e}') - click.echo(f'Ошибка подключения: {e}', err=True) - self.reader = None - self.writer = None - return False - - def _ensure_connected(self) -> bool: - """Проверяет, что соединение активно""" - return self.reader is not None and self.writer is not None and not self.writer.is_closing() - - async def _ensure_connection(self) -> bool: - """Проверяет и переподключается, если соединение потеряно""" - if not self._ensure_connected(): - client_logger.info('Не обнаружено активных соединений, подключаюсь...') - if not await self.connect(): - raise StorageConnectionError('Не удалось подключиться к серверу') - return True - - def _require_auth(self): - """Проверяет, что пользователь авторизован""" - if not self.authenticated: - raise AuthenticationError('Требуется авторизация. Используйте команду login.') - - async def disconnect(self): - """Отключается от сервера""" - if self.writer: - self.writer.close() - await self.writer.wait_closed() - self.authenticated = False - - async def register(self, login: str, password: str) -> bool: - """Регистрирует нового пользователя на сервере""" - try: - await self._ensure_connection() - - await ClientProtocol.send_json_message( - self.writer, {'command': 'REGISTER', 'login': login, 'password': password} - ) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - client_logger.info(f'Пользователь {login} зарегистрирован') - return True - error_msg = response.get('message', 'Ошибка регистрации') if response else 'Ошибка регистрации' - client_logger.warning(f'Ошибка регистрации: {error_msg}') - click.echo(f'Ошибка: {error_msg}', err=True) - return False - except StorageConnectionError as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при регистрации: {e}') - click.echo(f'Ошибка при регистрации: {e}', err=True) - return False - - async def login(self, login: str, password: str) -> bool: - """Авторизуется на сервере""" - try: - await self._ensure_connection() - - await ClientProtocol.send_json_message( - self.writer, {'command': 'AUTH', 'login': login, 'password': password} - ) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - self.authenticated = True - client_logger.info(f'Пользователь {login} авторизован') - return True - error_msg = response.get('message', 'Ошибка авторизации') if response else 'Ошибка авторизации' - client_logger.warning(f'Ошибка авторизации: {error_msg}') - click.echo(f'Ошибка: {error_msg}', err=True) - return False - except StorageConnectionError as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при авторизации: {e}') - click.echo(f'Ошибка при авторизации: {e}', err=True) - return False - - async def logout(self) -> bool: - """Выходит из аккаунта на сервере""" - try: - await self._ensure_connection() - - await ClientProtocol.send_json_message(self.writer, {'command': 'LOGOUT'}) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - self.authenticated = False - client_logger.info('Выход из аккаунта выполнен') - return True - error_msg = response.get('message', 'Ошибка выхода') if response else 'Ошибка выхода' - client_logger.warning(f'Ошибка выхода: {error_msg}') - click.echo(f'Ошибка: {error_msg}', err=True) - return False - except StorageConnectionError as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при выходе: {e}') - click.echo(f'Ошибка при выходе: {e}', err=True) - return False - - async def list_files(self, path: str = '') -> list | None: - """Получает список файлов""" - try: - await self._ensure_connection() - self._require_auth() - - await ClientProtocol.send_json_message(self.writer, {'command': 'LIST', 'path': path}) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - files = response.get('data', {}).get('files', []) - client_logger.debug(f'Получен список файлов: {len(files)} элементов') - return files - error_msg = response.get('message', 'Ошибка') if response else 'Ошибка' - client_logger.warning(f'Ошибка получения списка файлов: {error_msg}') - click.echo(f'Ошибка: {error_msg}', err=True) - return None - except (StorageConnectionError, AuthenticationError) as e: - click.echo(str(e), err=True) - return None - except Exception as e: - client_logger.error(f'Ошибка при получении списка файлов: {e}') - click.echo(f'Ошибка при получении списка файлов: {e}', err=True) - return None - - async def get_file(self, remote_path: str, local_path: str) -> bool: - """Скачивает файл с сервера""" - try: - await self._ensure_connection() - self._require_auth() - - await ClientProtocol.send_json_message(self.writer, {'command': 'GET', 'path': remote_path}) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - file_size = response.get('data', {}).get('size', 0) - client_logger.info(f'Скачивание файла {remote_path} ({file_size} байт)') - - if file_size > 1024 * 512: - with click.progressbar(length=file_size, label='Скачивание') as bar: - file_data = await ClientProtocol.read_binary_data(self.reader) - if file_data: - bar.update(len(file_data)) - else: - file_data = await ClientProtocol.read_binary_data(self.reader) - - if file_data: - local_file = Path(local_path).expanduser() - local_file.parent.mkdir(parents=True, exist_ok=True) - local_file.write_bytes(file_data) - client_logger.info(f'Файл сохранен: {local_path}') - click.echo(f'Файл сохранен: {local_path}') - return True - raise FileError('Не удалось получить данные файла') - error_msg = response.get('message', 'Ошибка') if response else 'Ошибка' - raise FileError(error_msg) - except (StorageConnectionError, AuthenticationError, FileError) as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при получении файла: {e}') - click.echo(f'Ошибка при получении файла: {e}', err=True) - return False - - async def put_file(self, local_path: str, remote_path: str) -> bool: - """Загружает файл на сервер""" - try: - await self._ensure_connection() - self._require_auth() - - local_file = Path(local_path).expanduser() - if not local_file.exists(): - raise FileError(f'Файл не найден: {local_path}') - - file_size = local_file.stat().st_size - if file_size > config.max_file_size: - raise FileError(f'Файл слишком большой: {file_size} байт (максимум: {config.max_file_size})') - - client_logger.info(f'Загрузка файла {local_path} -> {remote_path} ({file_size} байт)') - - file_data = local_file.read_bytes() - - await ClientProtocol.send_json_message( - self.writer, {'command': 'PUT', 'path': remote_path, 'size': len(file_data)} - ) - - if file_size > 1024 * 512: - with click.progressbar(length=file_size, label='Загрузка') as bar: - await ClientProtocol.send_binary_data(self.writer, file_data) - bar.update(file_size) - else: - await ClientProtocol.send_binary_data(self.writer, file_data) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - client_logger.info(f'Файл загружен: {remote_path}') - click.echo(f'Файл загружен: {remote_path}') - return True - error_msg = response.get('message', 'Ошибка') if response else 'Ошибка' - raise FileError(error_msg) - except (StorageConnectionError, AuthenticationError, FileError) as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при загрузке файла: {e}') - click.echo(f'Ошибка при загрузке файла: {e}', err=True) - return False - - async def delete_file(self, path: str) -> bool: - """Удаляет файл или директорию на сервере""" - try: - await self._ensure_connection() - self._require_auth() - - client_logger.info(f'Удаление: {path}') - await ClientProtocol.send_json_message(self.writer, {'command': 'DELETE', 'path': path}) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - client_logger.info(f'Удалено: {path}') - click.echo(f'Удалено: {path}') - return True - error_msg = response.get('message', 'Ошибка') if response else 'Ошибка' - raise FileError(error_msg) - except (StorageConnectionError, AuthenticationError, FileError) as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при удалении: {e}') - click.echo(f'Ошибка при удалении: {e}', err=True) - return False - - async def move_file(self, source_path: str, destination_path: str) -> bool: - """Перемещает или переименовывает файл или директорию на сервере""" - try: - await self._ensure_connection() - self._require_auth() - - client_logger.info(f'Перемещение: {source_path} -> {destination_path}') - await ClientProtocol.send_json_message( - self.writer, {'command': 'MOVE', 'source': source_path, 'destination': destination_path} - ) - - response = await ClientProtocol.read_json_message(self.reader) - if response and response.get('status') == 'OK': - client_logger.info(f'Перемещено: {source_path} -> {destination_path}') - click.echo(f'Перемещено: {source_path} -> {destination_path}') - return True - error_msg = response.get('message', 'Ошибка') if response else 'Ошибка' - raise FileError(error_msg) - except (StorageConnectionError, AuthenticationError, FileError) as e: - click.echo(str(e), err=True) - return False - except Exception as e: - client_logger.error(f'Ошибка при перемещении: {e}') - click.echo(f'Ошибка при перемещении: {e}', err=True) - return False diff --git a/src/client/protocol.py b/src/client/protocol.py deleted file mode 100644 index ad895d5..0000000 --- a/src/client/protocol.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio -import json -import struct -from typing import Any - -from src.utils.config import config -from src.utils.exceptions import StorageConnectionError as ConnectionError - - -class ClientProtocol: - """Протокол для взаимодействия с сервером""" - - @staticmethod - async def send_json_message(writer: asyncio.StreamWriter, data: dict[str, Any]) -> None: - """Отправляет JSON сообщение серверу""" - try: - json_data = json.dumps(data, ensure_ascii=False).encode('utf-8') - length = len(json_data) - - writer.write(struct.pack('>I', length)) - writer.write(json_data) - await writer.drain() - except Exception as e: - raise ConnectionError(f'Error sending JSON message: {e}') from e - - @staticmethod - async def send_binary_data(writer: asyncio.StreamWriter, data: bytes) -> None: - """Отправляет бинарные данные серверу""" - try: - writer.write(struct.pack('>I', len(data))) - await writer.drain() - - offset = 0 - while offset < len(data): - chunk = data[offset : offset + config.chunk_size] - writer.write(chunk) - await writer.drain() - offset += len(chunk) - except Exception as e: - raise ConnectionError(f'Error sending binary data: {e}') from e - - @staticmethod - async def read_json_message(reader: asyncio.StreamReader) -> dict[str, Any] | None: - """Читает JSON сообщение из потока""" - try: - length_bytes = await reader.readexactly(4) - length = struct.unpack('>I', length_bytes)[0] - - json_data = await reader.readexactly(length) - return json.loads(json_data.decode('utf-8')) - except Exception as e: - raise ConnectionError(f'Error reading JSON message: {e}') from e - - @staticmethod - async def read_binary_data(reader: asyncio.StreamReader) -> bytes | None: - """Читает бинарные данные из потока""" - try: - length_bytes = await reader.readexactly(4) - length = struct.unpack('>I', length_bytes)[0] - - return await reader.readexactly(length) - except Exception as e: - raise ConnectionError(f'Error reading binary data: {e}') from e diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..a188200 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,85 @@ +from pathlib import Path + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Config(BaseSettings): + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') + + SENSITIVE_DATA: list[str] = [ + 'password', + 'code', + ] + + APP_NAME: str = 'NetVault' + APP_VERSION: str = '0.1.0' + APP_HOST: str = '0.0.0.0' + APP_PORT: int = 8000 + DEBUG: bool = False + + EXTERNAL_ADDRESS: str + SECURE: bool = False + + DEFAULT_STORAGE_QUOTA: int = 5 * 1024 * 1024 * 1024 # 5GB + UPLOAD_THRESHOLD: int = 5 * 1024 * 1024 # 5MB + UPLOAD_CHUNK_SIZE: int = 5 * 1024 * 1024 # 5MB + + MINIO_ENDPOINT: str + MINIO_EXTERNAL_ENDPOINT: str | None = None + MINIO_ACCESS_KEY: str + MINIO_SECRET_KEY: SecretStr + MINIO_SECURE: bool = False + + DB_HOST: str + DB_PORT: str + DB_USER: str + DB_PASS: str + DB_NAME: str + + SQLALCHEMY_ECHO: bool = False + + @property + def POSTGRES_URL(self) -> str: + return f'postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}' + + REDIS_HOST: str + REDIS_PORT: int + REDIS_DB: int = 0 + REDIS_PASSWORD: str | None = None + CACHE_TTL: int | None = 30 + AUTH_CACHE_TTL: int | None = 600 + + @property + def REDIS_URL(self) -> str: + auth = f':{self.REDIS_PASSWORD}@' if self.REDIS_PASSWORD else '' + return f'redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + + YC_POSTBOX_ACCESS_KEY: str + YC_POSTBOX_SECRET_KEY: SecretStr + YC_POSTBOX_REGION: str = 'ru-central1' + YC_POSTBOX_ENDPOINT: str = 'https://postbox.cloud.yandex.net' + + MAIL_FROM: str + + BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent + + LOG_TO_FILE: bool = False + LOG_LEVEL: str = 'INFO' + LOG_FORMAT: str = '%(asctime)s | %(levelname)-8s | %(name)s | [%(filename)s:%(funcName)s:%(lineno)d] - %(message)s' + LOG_DATE_FORMAT: str = '%Y-%m-%d %H:%M:%S.%f' + + _LOGS_DIR: Path = BASE_DIR / 'logs' + + @property + def LOGS_DIR(self) -> Path: + Path.mkdir(self._LOGS_DIR, parents=True, exist_ok=True) + return self._LOGS_DIR + + JWT_SECRET: SecretStr + JWT_ALGORITHM: str = 'HS256' + ACCESS_TOKEN_EXP_MIN: int = 30 + REFRESH_TOKEN_EXP_MIN: int = 30 * 24 * 60 + + +config = Config() diff --git a/src/core/logger.py b/src/core/logger.py new file mode 100644 index 0000000..34c9fb4 --- /dev/null +++ b/src/core/logger.py @@ -0,0 +1,85 @@ +import json +import logging +from logging.handlers import RotatingFileHandler +from typing import Any + +import structlog + +from src.core.config import config + + +def setup_logging(level: int | str) -> None: + handlers = [] + + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + handlers.append(console_handler) + + if config.LOG_TO_FILE: + file_handler = RotatingFileHandler( + config.LOGS_DIR / f"{config.APP_NAME}_{config.APP_VERSION}.log", + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + + file_handler.setLevel(level) + handlers.append(file_handler) + + logging.basicConfig( + format="%(message)s", + handlers=handlers, + level=level, + ) + + +def get_logger(name: str, level: int | str = config.LOG_LEVEL) -> structlog.BoundLogger: + setup_logging(level) + render_method = ( + structlog.dev.ConsoleRenderer() + if config.DEBUG + else structlog.processors.JSONRenderer() + ) + structlog.configure( + processors=[ + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt=config.LOG_DATE_FORMAT, utc=True), + structlog.processors.CallsiteParameterAdder( + parameters=[ + structlog.processors.CallsiteParameter.MODULE, + structlog.processors.CallsiteParameter.FUNC_NAME, + structlog.processors.CallsiteParameter.LINENO, + ] + ), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + mask_sensitive_data, + render_method, + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + return structlog.get_logger(name) + + +def mask_sensitive_data(logger, method_name, event_dict: dict[str, Any]) -> dict[str, Any]: + def _mask(obj: Any) -> Any: + if isinstance(obj, dict): + return { + key: _mask(value) if key not in config.SENSITIVE_DATA else "***MASKED***" + for key, value in obj.items() + } + if isinstance(obj, list): + return [_mask(item) for item in obj] + if isinstance(obj, str) and obj.startswith("{") and obj.endswith("}"): + try: + return _mask(json.loads(obj)) + except json.JSONDecodeError: + return obj + return obj + + return _mask(event_dict) diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/connection.py b/src/database/connection.py new file mode 100644 index 0000000..4960b0f --- /dev/null +++ b/src/database/connection.py @@ -0,0 +1,34 @@ +from collections.abc import AsyncGenerator +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, + async_sessionmaker, +) + +from src.core.config import config + +engine = create_async_engine( + config.POSTGRES_URL, + echo=bool(config.DEBUG and config.SQLALCHEMY_ECHO), + future=True, +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=True, +) + + +async def get_session() -> AsyncGenerator[AsyncSession]: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() \ No newline at end of file diff --git a/src/database/enums/__init__.py b/src/database/enums/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py new file mode 100644 index 0000000..92e0260 --- /dev/null +++ b/src/database/models/__init__.py @@ -0,0 +1,25 @@ +from src.database.models.base import BaseORM, TimestampMixin +from src.database.models.bucket import BucketORM +from src.database.models.bucket_permission import BucketPermissionORM +from src.database.models.event_log import EventLogORM +from src.database.models.file import FileORM +from src.database.models.forlder import FolderORM +from src.database.models.minio_session import MinioSessionORM +from src.database.models.public_link import PublicLinkORM +from src.database.models.user import UserORM + + +__all__ = [ + 'BaseORM', + 'BucketORM', + 'BucketPermissionORM', + 'EventLogORM', + 'FileORM', + 'FolderORM', + 'MinioSessionORM', + 'PublicLinkORM', + 'TimestampMixin', + 'UserORM', +] + + diff --git a/src/database/models/base.py b/src/database/models/base.py new file mode 100644 index 0000000..bb97240 --- /dev/null +++ b/src/database/models/base.py @@ -0,0 +1,28 @@ +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import DateTime +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class BaseORM(DeclarativeBase): + __abstract__ = True + + id: Mapped[UUID] = mapped_column( + PG_UUID(as_uuid=True), + primary_key=True, + default=uuid4, + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) \ No newline at end of file diff --git a/src/database/models/bucket.py b/src/database/models/bucket.py new file mode 100644 index 0000000..bc3dddf --- /dev/null +++ b/src/database/models/bucket.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING + +from sqlalchemy import UniqueConstraint, String, UUID, ForeignKey, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.database.models import BaseORM, TimestampMixin + +if TYPE_CHECKING: + from src.database.models import UserORM + from src.database.models import FolderORM + from src.database.models import FileORM + +class BucketORM(BaseORM, TimestampMixin): + __tablename__ = "buckets" + __table_args__ = ( + UniqueConstraint("owner_id", "name", name="uq_bucket_owner_name"), + ) + + name: Mapped[str] = mapped_column(String, nullable=False) + owner_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + is_public: Mapped[bool] = mapped_column(Boolean, default=False) + minio_bucket_name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + + owner: Mapped["UserORM"] = relationship(back_populates="owned_buckets") + folders: Mapped[list["FolderORM"]] = relationship(back_populates="bucket") + files: Mapped[list["FileORM"]] = relationship(back_populates="bucket") \ No newline at end of file diff --git a/src/database/models/bucket_permission.py b/src/database/models/bucket_permission.py new file mode 100644 index 0000000..ce28686 --- /dev/null +++ b/src/database/models/bucket_permission.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import UniqueConstraint, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.database.models import BaseORM, TimestampMixin + +if TYPE_CHECKING: + from src.database.models import BucketORM + from src.database.models import UserORM + +class BucketPermissionORM(BaseORM, TimestampMixin): + __tablename__ = "bucket_permissions" + __table_args__ = ( + UniqueConstraint("bucket_id", "user_id", name="uq_bucket_user_permission"), + ) + + bucket_id: Mapped[UUID] = mapped_column(ForeignKey("buckets.id", ondelete="CASCADE")) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + permission_type: Mapped[str] = mapped_column(String) # read, write, admin + granted_by: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + + bucket: Mapped["BucketORM"] = relationship() + user: Mapped["UserORM"] = relationship(foreign_keys=[user_id]) \ No newline at end of file diff --git a/src/database/models/event_log.py b/src/database/models/event_log.py new file mode 100644 index 0000000..826e415 --- /dev/null +++ b/src/database/models/event_log.py @@ -0,0 +1,17 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.database.models import BaseORM, TimestampMixin + + +class EventLogORM(BaseORM, TimestampMixin): + __tablename__ = "event_log" + + user_id: Mapped[UUID | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + action: Mapped[str] = mapped_column(String) + entity_type: Mapped[str] = mapped_column(String) + entity_id: Mapped[UUID | None] = mapped_column(PG_UUID) + metadata_json: Mapped[dict] = mapped_column(JSONB, default=dict) diff --git a/src/database/models/file.py b/src/database/models/file.py new file mode 100644 index 0000000..4212eb1 --- /dev/null +++ b/src/database/models/file.py @@ -0,0 +1,38 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import UniqueConstraint, ForeignKey, BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.database.models import BaseORM, TimestampMixin + +if TYPE_CHECKING: + from src.database.models import BucketORM + from src.database.models import FolderORM + from src.database.models import UserORM + +class FileORM(BaseORM, TimestampMixin): + __tablename__ = "files" + __table_args__ = ( + UniqueConstraint("bucket_id", "storage_filename", name="uq_file_storage_name"), + ) + + original_filename: Mapped[str] = mapped_column(String, nullable=False) + storage_filename: Mapped[str] = mapped_column(String, nullable=False) + + path: Mapped[str] = mapped_column(String, nullable=False) + + bucket_id: Mapped[UUID] = mapped_column(ForeignKey("buckets.id")) + owner_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + folder_id: Mapped[UUID | None] = mapped_column( + ForeignKey("folders.id"), + nullable=True, + ) + + file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime_type: Mapped[str | None] = mapped_column(String) + file_hash: Mapped[str | None] = mapped_column(String) + + bucket: Mapped["BucketORM"] = relationship(back_populates="files") + folder: Mapped["FolderORM"] = relationship() + owner: Mapped["UserORM"] = relationship() \ No newline at end of file diff --git a/src/database/models/forlder.py b/src/database/models/forlder.py new file mode 100644 index 0000000..c9894f0 --- /dev/null +++ b/src/database/models/forlder.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import UniqueConstraint, ForeignKey, String, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.database.models import BaseORM, TimestampMixin + +if TYPE_CHECKING: + from src.database.models import BucketORM + + +class FolderORM(BaseORM, TimestampMixin): + __tablename__ = "folders" + __table_args__ = ( + UniqueConstraint("bucket_id", "parent_id", "name", name="uq_folder_path"), + ) + + bucket_id: Mapped[UUID] = mapped_column(ForeignKey("buckets.id", ondelete="CASCADE")) + parent_id: Mapped[UUID | None] = mapped_column(ForeignKey("folders.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String, nullable=False) + depth: Mapped[int] = mapped_column(Integer, default=0) + + bucket: Mapped["BucketORM"] = relationship(back_populates="folders") + parent: Mapped["FolderORM"] = relationship(remote_side="FolderORM.id", backref="subfolders") \ No newline at end of file diff --git a/src/database/models/minio_session.py b/src/database/models/minio_session.py new file mode 100644 index 0000000..3a14a83 --- /dev/null +++ b/src/database/models/minio_session.py @@ -0,0 +1,31 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ForeignKey, String, BigInteger, Integer, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from src.database.models import BaseORM, TimestampMixin + + +class MinioSessionORM(BaseORM, TimestampMixin): + __tablename__ = "minio_sessions" + + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) + bucket_id: Mapped[UUID] = mapped_column(ForeignKey("buckets.id")) + folder_id: Mapped[UUID | None] = mapped_column( + ForeignKey("folders.id"), + nullable=True, + ) + + operation_type: Mapped[str] = mapped_column(String) + minio_session_id: Mapped[str] = mapped_column(String) + object_name: Mapped[str] = mapped_column(String) + + object_size_bytes: Mapped[int] = mapped_column(BigInteger) + reserved_bytes: Mapped[int] = mapped_column(BigInteger) + + total_parts: Mapped[int | None] = mapped_column(Integer) + completed_parts: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String, default="active") + + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) \ No newline at end of file diff --git a/src/database/models/public_link.py b/src/database/models/public_link.py new file mode 100644 index 0000000..a10381d --- /dev/null +++ b/src/database/models/public_link.py @@ -0,0 +1,30 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ForeignKey, DateTime, Integer, CheckConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from src.database.models import BaseORM, TimestampMixin + + +class PublicLinkORM(BaseORM, TimestampMixin): + __tablename__ = "public_links" + + file_id: Mapped[UUID | None] = mapped_column( + ForeignKey("files.id", ondelete="CASCADE"), + nullable=True, + ) + folder_id: Mapped[UUID | None] = mapped_column( + ForeignKey("folders.id", ondelete="CASCADE"), + nullable=True, + ) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + max_downloads: Mapped[int | None] = mapped_column(Integer) + downloads_count: Mapped[int] = mapped_column(Integer, default=0) + + __table_args__ = ( + CheckConstraint( + "(file_id IS NOT NULL AND folder_id IS NULL) OR (file_id IS NULL AND folder_id IS NOT NULL)", + name="ck_public_links_one_target" + ), + ) \ No newline at end of file diff --git a/src/database/models/user.py b/src/database/models/user.py new file mode 100644 index 0000000..2e7aa13 --- /dev/null +++ b/src/database/models/user.py @@ -0,0 +1,24 @@ +from datetime import datetime, UTC +from typing import TYPE_CHECKING + +from sqlalchemy import String, BigInteger, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.database.models import BaseORM, TimestampMixin + +if TYPE_CHECKING: + from src.database.models import BucketORM + +class UserORM(BaseORM, TimestampMixin): + __tablename__ = 'users' + + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String, nullable=False) + + storage_quota_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + storage_used_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + storage_reserved_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + + last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False) + + owned_buckets: Mapped[list["BucketORM"]] = relationship(back_populates="owner") \ No newline at end of file diff --git a/src/database/repository/__init__.py b/src/database/repository/__init__.py new file mode 100644 index 0000000..4c99b1d --- /dev/null +++ b/src/database/repository/__init__.py @@ -0,0 +1,13 @@ +from src.database.repository.postgres import UserRepository, BucketRepository, BucketPermissionRepository, \ + FolderRepository, FileRepository, PublicLinkRepository + +__all__ = [ + 'BucketPermissionRepository', + 'BucketRepository', + 'FileRepository', + 'FolderRepository', + 'PublicLinkRepository', + 'UserRepository', +] + + diff --git a/src/database/repository/base/__init__.py b/src/database/repository/base/__init__.py new file mode 100644 index 0000000..a16667b --- /dev/null +++ b/src/database/repository/base/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.base.models import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin +from src.database.repository.base.repository import BaseRepository + + +__all__ = ['BaseModelIdentifiable', 'BaseRepository', 'PydanticToORMModelMixin', 'TimestampedModelMixin'] + diff --git a/src/database/repository/base/models.py b/src/database/repository/base/models.py new file mode 100644 index 0000000..2c0a8e7 --- /dev/null +++ b/src/database/repository/base/models.py @@ -0,0 +1,15 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class BaseModelIdentifiable(BaseModel): + id: UUID | None = None + +class PydanticToORMModelMixin(BaseModel): + model_config = ConfigDict(from_attributes=True) + +class TimestampedModelMixin(BaseModel): + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/database/repository/base/repository.py b/src/database/repository/base/repository.py new file mode 100644 index 0000000..d2069ff --- /dev/null +++ b/src/database/repository/base/repository.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from uuid import UUID + + +class BaseRepository[ORMType, ModelType](ABC): + @abstractmethod + async def get_by_id(self, _id: UUID) -> ModelType | None: + raise NotImplementedError + + @abstractmethod + async def get_many(self, limit: int | None = None) -> list[ModelType]: + raise NotImplementedError + + @abstractmethod + async def create(self, entity: ModelType) -> ModelType: + raise NotImplementedError + + @abstractmethod + async def update(self, entity: ModelType) -> ModelType: + raise NotImplementedError + + @abstractmethod + async def delete(self, _id: UUID) -> bool: + raise NotImplementedError diff --git a/src/database/repository/postgres/__init__.py b/src/database/repository/postgres/__init__.py new file mode 100644 index 0000000..bc9e86c --- /dev/null +++ b/src/database/repository/postgres/__init__.py @@ -0,0 +1,16 @@ +from src.database.repository.postgres.bucket import BucketRepository +from src.database.repository.postgres.bucket_permission import BucketPermissionRepository +from src.database.repository.postgres.file.repository import FileRepository +from src.database.repository.postgres.folder.repository import FolderRepository +from src.database.repository.postgres.public_link.repository import PublicLinkRepository +from src.database.repository.postgres.user import UserRepository + + +__all__ = [ + 'BucketPermissionRepository', + 'BucketRepository', + 'FileRepository', + 'FolderRepository', + 'PublicLinkRepository', + 'UserRepository', +] \ No newline at end of file diff --git a/src/database/repository/postgres/base/__init__.py b/src/database/repository/postgres/base/__init__.py new file mode 100644 index 0000000..17b1e99 --- /dev/null +++ b/src/database/repository/postgres/base/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.base.postgres_repository import BasePostgresRepository + +__all__ = [ + 'BasePostgresRepository', +] + diff --git a/src/database/repository/postgres/base/postgres_repository.py b/src/database/repository/postgres/base/postgres_repository.py new file mode 100644 index 0000000..215e0a1 --- /dev/null +++ b/src/database/repository/postgres/base/postgres_repository.py @@ -0,0 +1,85 @@ +from abc import ABC, abstractmethod +from typing import TypeVar, Annotated +from uuid import UUID + +from fastapi.params import Depends +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.database.connection import get_session +from src.database.models.base import BaseORM +from src.database.repository.base.models import BaseModelIdentifiable +from src.database.repository.base.repository import BaseRepository +from src.database.repository.postgres.errors import BasePostgresError + +ORMType = TypeVar('ORMType', bound=BaseORM) +ModelType = TypeVar('ModelType', bound=BaseModelIdentifiable) + + +class BasePostgresRepository( + BaseRepository[ORMType, ModelType], + ABC, +): + _orm_class: type[ORMType] + + def __init__( + self, + session: Annotated[AsyncSession, Depends(get_session)], + ) -> None: + self._session = session + + @abstractmethod + def orm_to_model(self, orm: ORMType) -> ModelType: + raise NotImplementedError + + @abstractmethod + def model_to_orm(self, model: ModelType) -> ORMType: + raise NotImplementedError + + async def get_by_id(self, _id: UUID) -> ModelType | None: + orm = await self._session.get(self._orm_class, _id) + if orm is None: + return None + return self.orm_to_model(orm) + + async def get_many(self, limit: int | None = None) -> list[ModelType]: + stmt = select(self._orm_class) + if limit is not None: + stmt = stmt.limit(limit) + + result = await self._session.execute(stmt) + return [self.orm_to_model(orm) for orm in result.scalars().all() if orm is not None] + + async def create(self, entity: ModelType) -> ModelType: + orm = self.model_to_orm(entity) + self._session.add(orm) + await self._session.commit() + await self._session.refresh(orm) + return self.orm_to_model(orm) + + async def update(self, entity: ModelType) -> ModelType: + orm = await self._session.get(self._orm_class, entity.id) + + if orm is None: + msg: str = f'Entity with id {entity.id} not found' + raise BasePostgresError(msg) + + updated_orm = self.model_to_orm(entity) + data = updated_orm.__dict__.copy() + data.pop('id', None) + data.pop('_sa_instance_state', None) + + for field, value in data.items(): + if not field.startswith('_'): + setattr(orm, field, value) + + await self._session.commit() + await self._session.refresh(orm) + + return self.orm_to_model(orm) + + async def delete(self, _id: UUID) -> bool: + query = delete(self._orm_class).where(self._orm_class.id == _id) + result = await self._session.execute(query) + await self._session.commit() + return result.rowcount > 0 # type: ignore[attr-defined] diff --git a/src/database/repository/postgres/bucket/__init__.py b/src/database/repository/postgres/bucket/__init__.py new file mode 100644 index 0000000..b490ad6 --- /dev/null +++ b/src/database/repository/postgres/bucket/__init__.py @@ -0,0 +1,5 @@ +from src.database.repository.postgres.bucket.repository import BucketRepository + +__all__ = [ + 'BucketRepository', +] diff --git a/src/database/repository/postgres/bucket/base/__init__.py b/src/database/repository/postgres/bucket/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/bucket/base/repository.py b/src/database/repository/postgres/bucket/base/repository.py new file mode 100644 index 0000000..25fee06 --- /dev/null +++ b/src/database/repository/postgres/bucket/base/repository.py @@ -0,0 +1,9 @@ +from abc import ABC + +from src.database.models import BucketORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.bucket.dtos import Bucket + + +class BaseBucketRepository(BasePostgresRepository[BucketORM, Bucket], ABC): + _orm_class = BucketORM \ No newline at end of file diff --git a/src/database/repository/postgres/bucket/dtos/__init__.py b/src/database/repository/postgres/bucket/dtos/__init__.py new file mode 100644 index 0000000..06351a4 --- /dev/null +++ b/src/database/repository/postgres/bucket/dtos/__init__.py @@ -0,0 +1,5 @@ +from src.database.repository.postgres.bucket.dtos.dtos import Bucket + +__all__ = [ + 'Bucket', +] \ No newline at end of file diff --git a/src/database/repository/postgres/bucket/dtos/dtos.py b/src/database/repository/postgres/bucket/dtos/dtos.py new file mode 100644 index 0000000..be46f4b --- /dev/null +++ b/src/database/repository/postgres/bucket/dtos/dtos.py @@ -0,0 +1,14 @@ +from uuid import UUID + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin +from src.database.repository.postgres.bucket_permission.dtos import PermissionType + + +class Bucket(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + name: str + owner_id: UUID + is_public: bool + minio_bucket_name: str + permission: PermissionType | None = None + files_count: int = 0 + size: int = 0 diff --git a/src/database/repository/postgres/bucket/repository.py b/src/database/repository/postgres/bucket/repository.py new file mode 100644 index 0000000..261118c --- /dev/null +++ b/src/database/repository/postgres/bucket/repository.py @@ -0,0 +1,56 @@ +from uuid import UUID + +from sqlalchemy import select, or_ + +from src.database.models import BucketORM, BucketPermissionORM + +from src.database.repository.postgres.bucket.base.repository import BaseBucketRepository +from src.database.repository.postgres.bucket.dtos import Bucket + + +class BucketRepository(BaseBucketRepository): + def orm_to_model(self, orm: BucketORM) -> Bucket: + return Bucket.model_validate(orm) + + def model_to_orm(self, model: Bucket) -> BucketORM: + return BucketORM(**model.model_dump(exclude={'permission', 'files_count', 'size'})) + + async def update(self, entity: Bucket) -> Bucket: + orm = await self._session.get(self._orm_class, entity.id) + + if orm is None: + raise ValueError(f'Entity with id {entity.id} not found') + + orm.name = entity.name + orm.is_public = entity.is_public + orm.minio_bucket_name = entity.minio_bucket_name + + await self._session.commit() + await self._session.refresh(orm) + + return self.orm_to_model(orm) + + async def get_accessible_buckets(self, user_id: UUID) -> list[Bucket]: + stmt = ( + select(BucketORM) + .outerjoin( + BucketPermissionORM, + (BucketPermissionORM.bucket_id == BucketORM.id) & (BucketPermissionORM.user_id == user_id), + ) + .where( + or_(BucketORM.owner_id == user_id, BucketPermissionORM.user_id == user_id, BucketORM.is_public == True) + ) + .distinct() + ) + + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def search_by_name(self, bucket_ids: list[UUID], query: str) -> list[Bucket]: + stmt = select(self._orm_class).where( + self._orm_class.id.in_(bucket_ids), self._orm_class.name.ilike(f'%{query}%') + ) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] diff --git a/src/database/repository/postgres/bucket_permission/__init__.py b/src/database/repository/postgres/bucket_permission/__init__.py new file mode 100644 index 0000000..f9c14dc --- /dev/null +++ b/src/database/repository/postgres/bucket_permission/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.bucket_permission.repository import BucketPermissionRepository + + +__all__ = [ + 'BucketPermissionRepository', +] \ No newline at end of file diff --git a/src/database/repository/postgres/bucket_permission/base/__init__.py b/src/database/repository/postgres/bucket_permission/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/bucket_permission/base/repository.py b/src/database/repository/postgres/bucket_permission/base/repository.py new file mode 100644 index 0000000..d581855 --- /dev/null +++ b/src/database/repository/postgres/bucket_permission/base/repository.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from src.database.models import BucketPermissionORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.bucket_permission.dtos import BucketPermission + + +class BaseBucketPermissionRepository(BasePostgresRepository[BucketPermissionORM, BucketPermission], ABC): + _orm_class = BucketPermissionORM + + @abstractmethod + async def get_user_permission(self, bucket_id: UUID, user_id: UUID) -> BucketPermission | None: + raise NotImplementedError + + @abstractmethod + async def get_by_bucket_and_user(self, bucket_id: UUID, user_id: UUID) -> BucketPermission | None: + raise NotImplementedError + + @abstractmethod + async def get_user_ids_by_bucket(self, bucket_id: UUID) -> list[UUID]: + raise NotImplementedError \ No newline at end of file diff --git a/src/database/repository/postgres/bucket_permission/dtos/__init__.py b/src/database/repository/postgres/bucket_permission/dtos/__init__.py new file mode 100644 index 0000000..c748aa7 --- /dev/null +++ b/src/database/repository/postgres/bucket_permission/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.bucket_permission.dtos.dtos import BucketPermission, PermissionType + +__all__ = [ + 'BucketPermission', + 'PermissionType', +] diff --git a/src/database/repository/postgres/bucket_permission/dtos/dtos.py b/src/database/repository/postgres/bucket_permission/dtos/dtos.py new file mode 100644 index 0000000..5b75d35 --- /dev/null +++ b/src/database/repository/postgres/bucket_permission/dtos/dtos.py @@ -0,0 +1,20 @@ +from enum import StrEnum +from uuid import UUID + +from pydantic import ConfigDict + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin + + +class PermissionType(StrEnum): + READ = 'read' + WRITE = 'write' + ADMIN = 'admin' + +class BucketPermission(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + model_config = ConfigDict(use_enum_values=True) + + bucket_id: UUID + user_id: UUID + permission_type: PermissionType + granted_by: UUID \ No newline at end of file diff --git a/src/database/repository/postgres/bucket_permission/repository.py b/src/database/repository/postgres/bucket_permission/repository.py new file mode 100644 index 0000000..833825a --- /dev/null +++ b/src/database/repository/postgres/bucket_permission/repository.py @@ -0,0 +1,45 @@ +from uuid import UUID + +from sqlalchemy import select + +from src.database.models import BucketPermissionORM + +from src.database.repository.postgres.bucket_permission.base.repository import BaseBucketPermissionRepository +from src.database.repository.postgres.bucket_permission.dtos import BucketPermission + + +class BucketPermissionRepository(BaseBucketPermissionRepository): + def orm_to_model(self, orm: BucketPermissionORM) -> BucketPermission: + return BucketPermission.model_validate(orm) + + def model_to_orm(self, model: BucketPermission) -> BucketPermissionORM: + return BucketPermissionORM(**model.model_dump()) + + async def get_user_permission(self, bucket_id: UUID, user_id: UUID) -> BucketPermission | None: + stmt = select(self._orm_class).where( + BucketPermissionORM.bucket_id == bucket_id, BucketPermissionORM.user_id == user_id + ) + result = await self._session.execute(stmt) + orm = result.scalar_one_or_none() + if orm: + return self.orm_to_model(orm) + return None + + async def get_by_bucket_and_user(self, bucket_id: UUID, user_id: UUID) -> BucketPermission | None: + stmt = select(self._orm_class).where(self._orm_class.bucket_id == bucket_id, self._orm_class.user_id == user_id) + result = await self._session.execute(stmt) + orm = result.scalar_one_or_none() + if orm: + return self.orm_to_model(orm) + return None + + async def get_user_ids_by_bucket(self, bucket_id: UUID) -> list[UUID]: + stmt = select(self._orm_class.user_id).where(self._orm_class.bucket_id == bucket_id) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_permissions_by_bucket(self, bucket_id: UUID) -> list[BucketPermission]: + stmt = select(self._orm_class).where(self._orm_class.bucket_id == bucket_id) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] diff --git a/src/database/repository/postgres/errors.py b/src/database/repository/postgres/errors.py new file mode 100644 index 0000000..6676949 --- /dev/null +++ b/src/database/repository/postgres/errors.py @@ -0,0 +1,2 @@ +class BasePostgresError(Exception): + pass diff --git a/src/database/repository/postgres/file/__init__.py b/src/database/repository/postgres/file/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/file/base/__init__.py b/src/database/repository/postgres/file/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/file/base/repository.py b/src/database/repository/postgres/file/base/repository.py new file mode 100644 index 0000000..b696a07 --- /dev/null +++ b/src/database/repository/postgres/file/base/repository.py @@ -0,0 +1,9 @@ +from abc import ABC + +from src.database.models import FileORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.file.dtos import File + + +class BaseFileRepository(BasePostgresRepository[FileORM, File], ABC): + _orm_class = FileORM \ No newline at end of file diff --git a/src/database/repository/postgres/file/dtos/__init__.py b/src/database/repository/postgres/file/dtos/__init__.py new file mode 100644 index 0000000..edccda3 --- /dev/null +++ b/src/database/repository/postgres/file/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.file.dtos.dtos import File + + +__all__ = [ + 'File', +] \ No newline at end of file diff --git a/src/database/repository/postgres/file/dtos/dtos.py b/src/database/repository/postgres/file/dtos/dtos.py new file mode 100644 index 0000000..abdccd3 --- /dev/null +++ b/src/database/repository/postgres/file/dtos/dtos.py @@ -0,0 +1,21 @@ +from uuid import UUID + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin +from src.database.repository.postgres.bucket_permission.dtos import PermissionType + + +class File(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + original_filename: str + storage_filename: str + + path: str + + bucket_id: UUID + owner_id: UUID + folder_id: UUID | None = None + + file_size_bytes: int + mime_type: str | None + file_hash: str | None + + permission: PermissionType | None = None diff --git a/src/database/repository/postgres/file/repository.py b/src/database/repository/postgres/file/repository.py new file mode 100644 index 0000000..cc56fa5 --- /dev/null +++ b/src/database/repository/postgres/file/repository.py @@ -0,0 +1,92 @@ +from uuid import UUID + +from sqlalchemy import select, delete, func + +from src.database.models import FileORM + +from src.database.repository.postgres.file.base.repository import BaseFileRepository +from src.database.repository.postgres.file.dtos import File + + +class FileRepository(BaseFileRepository): + def orm_to_model(self, orm: FileORM) -> File: + return File.model_validate(orm) + + def model_to_orm(self, model: File) -> FileORM: + return FileORM(**model.model_dump(exclude={'permission'})) + + async def get_by_bucket_and_parent_and_name( + self, bucket_id: UUID, folder_id: UUID | None, name: str + ) -> File | None: + stmt = select(self._orm_class).where( + self._orm_class.bucket_id == bucket_id, + self._orm_class.folder_id == folder_id, + self._orm_class.original_filename == name, + ) + result = await self._session.execute(stmt) + orm = result.scalar_one_or_none() + return self.orm_to_model(orm) if orm else None + + async def get_recent_by_owner(self, owner_id: UUID, limit: int) -> list[File]: + stmt = ( + select(self._orm_class) + .where(self._orm_class.owner_id == owner_id) + .order_by(self._orm_class.updated_at.desc()) + .limit(limit) + ) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def search_by_name(self, bucket_ids: list[UUID], query: str) -> list[File]: + stmt = select(self._orm_class).where( + self._orm_class.bucket_id.in_(bucket_ids), self._orm_class.original_filename.ilike(f'%{query}%') + ) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def get_by_bucket_and_folder(self, bucket_id: UUID, folder_id: UUID | None) -> list[File]: + stmt = ( + select(self._orm_class) + .where(self._orm_class.bucket_id == bucket_id, self._orm_class.folder_id == folder_id) + .order_by(self._orm_class.original_filename) + ) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def get_by_folder_ids(self, folder_ids: list[UUID]) -> list[File]: + stmt = select(self._orm_class).where(self._orm_class.folder_id.in_(folder_ids)) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def delete_many(self, file_ids: list[UUID]) -> None: + if not file_ids: + return + stmt = delete(self._orm_class).where(self._orm_class.id.in_(file_ids)) + await self._session.execute(stmt) + + async def get_bucket_stats(self, bucket_id: UUID) -> tuple[int, int]: + stmt = select( + func.count(self._orm_class.id), func.coalesce(func.sum(self._orm_class.file_size_bytes), 0) + ).where(self._orm_class.bucket_id == bucket_id) + result = await self._session.execute(stmt) + row = result.one() + return int(row[0]), int(row[1] or 0) + + async def get_buckets_stats(self, bucket_ids: list[UUID]) -> dict[UUID, tuple[int, int]]: + if not bucket_ids: + return {} + stmt = ( + select( + self._orm_class.bucket_id, + func.count(self._orm_class.id), + func.coalesce(func.sum(self._orm_class.file_size_bytes), 0), + ) + .where(self._orm_class.bucket_id.in_(bucket_ids)) + .group_by(self._orm_class.bucket_id) + ) + result = await self._session.execute(stmt) + return {row[0]: (int(row[1]), int(row[2] or 0)) for row in result} diff --git a/src/database/repository/postgres/folder/__init__.py b/src/database/repository/postgres/folder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/folder/base/__init__.py b/src/database/repository/postgres/folder/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/folder/base/repository.py b/src/database/repository/postgres/folder/base/repository.py new file mode 100644 index 0000000..641340f --- /dev/null +++ b/src/database/repository/postgres/folder/base/repository.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from src.database.models import FolderORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.folder.dtos import Folder + + +class BaseFolderRepository(BasePostgresRepository[FolderORM, Folder], ABC): + _orm_class = FolderORM + + @abstractmethod + async def get_by_parent_and_name(self, bucket_id: UUID, parent_id: UUID | None, name: str) -> Folder | None: + raise NotImplementedError + + @abstractmethod + async def update_subtree_depth(self, root_id: UUID, delta: int) -> None: + raise NotImplementedError + + @abstractmethod + async def is_descendant(self, folder_id: UUID, ancestor_id: UUID) -> bool: + raise NotImplementedError + + @abstractmethod + async def get_subtree_ids(self, root_id: UUID) -> list[UUID]: + raise NotImplementedError + + @abstractmethod + async def delete_many(self, ids: list[UUID]) -> None: + raise NotImplementedError diff --git a/src/database/repository/postgres/folder/dtos/__init__.py b/src/database/repository/postgres/folder/dtos/__init__.py new file mode 100644 index 0000000..39ea8fc --- /dev/null +++ b/src/database/repository/postgres/folder/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.folder.dtos.dtos import Folder + + +__all__ = [ + 'Folder', +] \ No newline at end of file diff --git a/src/database/repository/postgres/folder/dtos/dtos.py b/src/database/repository/postgres/folder/dtos/dtos.py new file mode 100644 index 0000000..e3269f5 --- /dev/null +++ b/src/database/repository/postgres/folder/dtos/dtos.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin +from src.database.repository.postgres.bucket_permission.dtos import PermissionType + + +class Folder(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + bucket_id: UUID + parent_id: UUID | None + name: str + depth: int + + permission: PermissionType | None = None diff --git a/src/database/repository/postgres/folder/repository.py b/src/database/repository/postgres/folder/repository.py new file mode 100644 index 0000000..5369972 --- /dev/null +++ b/src/database/repository/postgres/folder/repository.py @@ -0,0 +1,93 @@ +from uuid import UUID + +from sqlalchemy import select, text, delete + +from src.database.models import FolderORM +from src.database.repository.postgres.folder.base.repository import BaseFolderRepository +from src.database.repository.postgres.folder.dtos import Folder + + +class FolderRepository(BaseFolderRepository): + def orm_to_model(self, orm: FolderORM) -> Folder: + return Folder.model_validate(orm) + + def model_to_orm(self, model: Folder) -> FolderORM: + return FolderORM(**model.model_dump(exclude={'permission'})) + + async def get_by_parent_and_name(self, bucket_id: UUID, parent_id: UUID | None, name: str) -> Folder | None: + stmt = select(self._orm_class).where( + self._orm_class.bucket_id == bucket_id, + self._orm_class.parent_id == parent_id, + self._orm_class.name == name + ) + result = await self._session.execute(stmt) + orm = result.scalar_one_or_none() + return self.orm_to_model(orm) if orm else None + + async def update_subtree_depth(self, root_id: UUID, delta: int) -> None: + query = text(""" + WITH RECURSIVE subtree AS ( + SELECT id, depth FROM folders WHERE id = :root_id + UNION ALL + SELECT f.id, f.depth FROM folders f + JOIN subtree s ON f.parent_id = s.id + ) + UPDATE folders SET depth = depth + :delta + WHERE id IN (SELECT id FROM subtree WHERE id != :root_id) + """) + await self._session.execute(query, {"root_id": root_id, "delta": delta}) + await self._session.commit() + + async def is_descendant(self, folder_id: UUID, ancestor_id: UUID) -> bool: + """Возвращает True, если folder_id является потомком ancestor_id.""" + query = text(""" + WITH RECURSIVE ancestors AS ( + SELECT id, parent_id FROM folders WHERE id = :folder_id + UNION ALL + SELECT f.id, f.parent_id FROM folders f + JOIN ancestors a ON f.id = a.parent_id + ) + SELECT id FROM ancestors WHERE id = :ancestor_id + """) + result = await self._session.execute(query, {"folder_id": folder_id, "ancestor_id": ancestor_id}) + return result.scalar_one_or_none() is not None + + async def get_subtree_ids(self, root_id: UUID) -> list[UUID]: + query = text(""" + WITH RECURSIVE subtree AS ( + SELECT id FROM folders WHERE id = :root_id + UNION ALL + SELECT f.id FROM folders f + INNER JOIN subtree s ON f.parent_id = s.id + ) + SELECT id FROM subtree + """) + result = await self._session.execute(query, {"root_id": root_id}) + rows = result.fetchall() + return [row[0] for row in rows] + + async def delete_many(self, ids: list[UUID]) -> None: + if not ids: + return + stmt = delete(self._orm_class).where(self._orm_class.id.in_(ids)) + await self._session.execute(stmt) + + async def search_by_name(self, bucket_ids: list[UUID], query: str) -> list[Folder]: + stmt = select(self._orm_class).where( + self._orm_class.bucket_id.in_(bucket_ids), + self._orm_class.name.ilike(f"%{query}%") + ) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def get_by_bucket_and_parent( + self, bucket_id: UUID, parent_id: UUID | None + ) -> list[Folder]: + stmt = select(self._orm_class).where( + self._orm_class.bucket_id == bucket_id, + self._orm_class.parent_id == parent_id + ).order_by(self._orm_class.name) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] \ No newline at end of file diff --git a/src/database/repository/postgres/minio_session/__init__.py b/src/database/repository/postgres/minio_session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/minio_session/base/__init__.py b/src/database/repository/postgres/minio_session/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/minio_session/base/repository.py b/src/database/repository/postgres/minio_session/base/repository.py new file mode 100644 index 0000000..806f6e1 --- /dev/null +++ b/src/database/repository/postgres/minio_session/base/repository.py @@ -0,0 +1,9 @@ +from abc import ABC + +from src.database.models import MinioSessionORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.minio_session.dtos import MinioSession + + +class BaseMinioSessionRepository(BasePostgresRepository[MinioSessionORM, MinioSession], ABC): + _orm_class = MinioSessionORM diff --git a/src/database/repository/postgres/minio_session/dtos/__init__.py b/src/database/repository/postgres/minio_session/dtos/__init__.py new file mode 100644 index 0000000..d67a629 --- /dev/null +++ b/src/database/repository/postgres/minio_session/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.minio_session.dtos.dtos import MinioSession + + +__all__ = [ + 'MinioSession', +] \ No newline at end of file diff --git a/src/database/repository/postgres/minio_session/dtos/dtos.py b/src/database/repository/postgres/minio_session/dtos/dtos.py new file mode 100644 index 0000000..70e4da0 --- /dev/null +++ b/src/database/repository/postgres/minio_session/dtos/dtos.py @@ -0,0 +1,23 @@ +from datetime import datetime +from uuid import UUID + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin + + +class MinioSession(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + user_id: UUID + bucket_id: UUID + folder_id: UUID + + operation_type: str + minio_session_id: str + object_name: str + + object_size_bytes: int + reserved_bytes: int + + total_parts: int | None + completed_parts: int + status: str + + expires_at: datetime \ No newline at end of file diff --git a/src/database/repository/postgres/minio_session/repository.py b/src/database/repository/postgres/minio_session/repository.py new file mode 100644 index 0000000..30204c7 --- /dev/null +++ b/src/database/repository/postgres/minio_session/repository.py @@ -0,0 +1,12 @@ +from src.database.models import MinioSessionORM + +from src.database.repository.postgres.minio_session.base.repository import BaseMinioSessionRepository +from src.database.repository.postgres.minio_session.dtos import MinioSession + + +class MinioSessionRepository(BaseMinioSessionRepository): + def orm_to_model(self, orm: MinioSessionORM) -> MinioSession: + return MinioSession.model_validate(orm) + + def model_to_orm(self, model: MinioSession) -> MinioSessionORM: + return MinioSessionORM(**model.model_dump()) diff --git a/src/database/repository/postgres/public_link/__init__.py b/src/database/repository/postgres/public_link/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/public_link/base/__init__.py b/src/database/repository/postgres/public_link/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/database/repository/postgres/public_link/base/repository.py b/src/database/repository/postgres/public_link/base/repository.py new file mode 100644 index 0000000..e70c321 --- /dev/null +++ b/src/database/repository/postgres/public_link/base/repository.py @@ -0,0 +1,9 @@ +from abc import ABC + +from src.database.models import PublicLinkORM +from src.database.repository.postgres.base import BasePostgresRepository +from src.database.repository.postgres.public_link.dtos import PublicLink + + +class BasePublicLinkRepository(BasePostgresRepository[PublicLinkORM, PublicLink], ABC): + _orm_class = PublicLinkORM diff --git a/src/database/repository/postgres/public_link/dtos/__init__.py b/src/database/repository/postgres/public_link/dtos/__init__.py new file mode 100644 index 0000000..ac9b4dd --- /dev/null +++ b/src/database/repository/postgres/public_link/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.public_link.dtos.dtos import PublicLink + + +__all__ = [ + 'PublicLink', +] \ No newline at end of file diff --git a/src/database/repository/postgres/public_link/dtos/dtos.py b/src/database/repository/postgres/public_link/dtos/dtos.py new file mode 100644 index 0000000..b2884c2 --- /dev/null +++ b/src/database/repository/postgres/public_link/dtos/dtos.py @@ -0,0 +1,12 @@ +from datetime import datetime +from uuid import UUID + +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin + + +class PublicLink(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + file_id: UUID | None + folder_id: UUID | None + expires_at: datetime | None = None + max_downloads: int | None = None + downloads_count: int = 0 diff --git a/src/database/repository/postgres/public_link/repository.py b/src/database/repository/postgres/public_link/repository.py new file mode 100644 index 0000000..7c8f779 --- /dev/null +++ b/src/database/repository/postgres/public_link/repository.py @@ -0,0 +1,28 @@ +from uuid import UUID + +from sqlalchemy import select + +from src.database.models import PublicLinkORM + +from src.database.repository.postgres.public_link.base.repository import BasePublicLinkRepository +from src.database.repository.postgres.public_link.dtos import PublicLink + + +class PublicLinkRepository(BasePublicLinkRepository): + def orm_to_model(self, orm: PublicLinkORM) -> PublicLink: + return PublicLink.model_validate(orm) + + def model_to_orm(self, model: PublicLink) -> PublicLinkORM: + return PublicLinkORM(**model.model_dump()) + + async def get_by_file(self, file_id: UUID) -> list[PublicLink]: + stmt = select(self._orm_class).where(self._orm_class.file_id == file_id) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] + + async def get_by_folder(self, folder_id: UUID) -> list[PublicLink]: + stmt = select(self._orm_class).where(self._orm_class.folder_id == folder_id) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] diff --git a/src/database/repository/postgres/user/__init__.py b/src/database/repository/postgres/user/__init__.py new file mode 100644 index 0000000..701d7e3 --- /dev/null +++ b/src/database/repository/postgres/user/__init__.py @@ -0,0 +1,5 @@ +from src.database.repository.postgres.user.repository import UserRepository + +__all__ = [ + 'UserRepository', +] \ No newline at end of file diff --git a/src/database/repository/postgres/user/base/__init__.py b/src/database/repository/postgres/user/base/__init__.py new file mode 100644 index 0000000..b7f0a8c --- /dev/null +++ b/src/database/repository/postgres/user/base/__init__.py @@ -0,0 +1,5 @@ +from src.database.repository.postgres.user.base.repository import BaseUserRepository + +__all__ = [ + 'BaseUserRepository', +] \ No newline at end of file diff --git a/src/database/repository/postgres/user/base/repository.py b/src/database/repository/postgres/user/base/repository.py new file mode 100644 index 0000000..c55f97b --- /dev/null +++ b/src/database/repository/postgres/user/base/repository.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from src.database.models import UserORM +from src.database.repository.postgres.base.postgres_repository import BasePostgresRepository +from src.database.repository.postgres.user.dtos.dtos import User + + +class BaseUserRepository(BasePostgresRepository[UserORM, User], ABC): + _orm_class = UserORM + + @abstractmethod + async def get_by_email(self, email: str) -> User | None: + raise NotImplementedError + + @abstractmethod + async def get_by_ids(self, user_ids: list[UUID]) -> list[User]: + raise NotImplementedError diff --git a/src/database/repository/postgres/user/dtos/__init__.py b/src/database/repository/postgres/user/dtos/__init__.py new file mode 100644 index 0000000..b749fdb --- /dev/null +++ b/src/database/repository/postgres/user/dtos/__init__.py @@ -0,0 +1,6 @@ +from src.database.repository.postgres.user.dtos.dtos import User + +__all__ = [ + 'User', +] + diff --git a/src/database/repository/postgres/user/dtos/dtos.py b/src/database/repository/postgres/user/dtos/dtos.py new file mode 100644 index 0000000..f1e80d1 --- /dev/null +++ b/src/database/repository/postgres/user/dtos/dtos.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from src.core.config import config +from src.database.repository.base import BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin + + +class User(BaseModelIdentifiable, PydanticToORMModelMixin, TimestampedModelMixin): + email: str + password_hash: str | None = None + storage_quota_bytes: int = config.DEFAULT_STORAGE_QUOTA + storage_used_bytes: int = 0 + storage_reserved_bytes: int = 0 + + last_login_at: datetime | None = None diff --git a/src/database/repository/postgres/user/repository.py b/src/database/repository/postgres/user/repository.py new file mode 100644 index 0000000..daff8da --- /dev/null +++ b/src/database/repository/postgres/user/repository.py @@ -0,0 +1,31 @@ +from uuid import UUID + +from sqlalchemy import select + +from src.database.models import UserORM +from src.database.repository.postgres.user.base import BaseUserRepository +from src.database.repository.postgres.user.dtos import User + + +class UserRepository(BaseUserRepository): + def orm_to_model(self, orm: UserORM) -> User: + return User.model_validate(orm) + + def model_to_orm(self, model: User) -> UserORM: + return UserORM(**model.model_dump()) + + async def get_by_email(self, email: str) -> User | None: + stmt = select(self._orm_class).where(UserORM.email == email) + + result = await self._session.execute(stmt) + user = result.scalar_one_or_none() + if not user: + return None + + return self.orm_to_model(user) + + async def get_by_ids(self, user_ids: list[UUID]) -> list[User]: + stmt = select(self._orm_class).where(self._orm_class.id.in_(user_ids)) + result = await self._session.execute(stmt) + orms = result.scalars().all() + return [self.orm_to_model(orm) for orm in orms] \ No newline at end of file diff --git a/src/exceptions/__init__.py b/src/exceptions/__init__.py new file mode 100644 index 0000000..ad1e046 --- /dev/null +++ b/src/exceptions/__init__.py @@ -0,0 +1,88 @@ +import logging + +class BaseServerError(Exception): + level = logging.NOTSET + http_code: int + message: str | None + code: str | None + + def __init__( + self, + message: str | None = None, + *, + code: str | None = None, + level: int | None = None, + http_code: int | None = None, + ): + super().__init__(message) + self.message = message or self.message + self.code = code or self.code + self.level = level or self.level + self.http_code = http_code or self.http_code + + +class ServerError(BaseServerError): + level = logging.ERROR + http_code = 500 + + +class ClientError(BaseServerError): + level = logging.WARNING + http_code = 400 + + +class NotFound(ClientError): + http_code = 404 + code = 'not_found' + message = 'The requested resource is not found' + + +class PermissionDenied(ClientError): + http_code = 403 + code = 'permission_denied' + message = 'Insufficient permissions' + +class Conflict(ClientError): + http_code = 409 + code = 'conflict' + message = 'The conflict was occurred while processing the request' + +class RedisException(ClientError): + level = logging.ERROR + http_code = 500 + code = 'redis_exception' + message = 'Redis server error' + +# --------- AUTH --------- +class EmailAlreadyExists(ClientError): + code = "email_exists" + message = "User with this email already exists" + http_code = 409 + +class InvalidCredentials(ClientError): + code = "invalid_credentials" + message = "Invalid email or password" + +class SessionExpired(ClientError): + code = "session_expired" + message = "Session expired or not found" + +class InvalidSessionData(ClientError): + code = "invalid_session_data" + message = "Invalid session data" + +class InvalidCode(ClientError): + code = "invalid_code" + message = "Invalid verification code" + +class CodeExpired(ClientError): + code = "code_expired" + message = "Code expired" + +class InvalidSessionType(ClientError): + code = "invalid_session_type" + message = "Invalid session type" + +class InvalidToken(ClientError): + code = "invalid_token" + message = "Invalid or expired token" \ No newline at end of file diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py new file mode 100644 index 0000000..f342e25 --- /dev/null +++ b/src/handlers/__init__.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from src.handlers.api import api_router +from src.handlers.public import public_router + +app_router = APIRouter() +app_router.include_router(public_router) +app_router.include_router(api_router) + +ROUTERS: list[APIRouter] = [ + app_router, +] + +__all__ = [ + 'ROUTERS', +] diff --git a/src/handlers/api/__init__.py b/src/handlers/api/__init__.py new file mode 100644 index 0000000..b9b014e --- /dev/null +++ b/src/handlers/api/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from src.handlers.api.v1 import v1_router + +api_router = APIRouter(prefix='/api') +api_router.include_router(v1_router) diff --git a/src/handlers/api/v1/__init__.py b/src/handlers/api/v1/__init__.py new file mode 100644 index 0000000..2d9557f --- /dev/null +++ b/src/handlers/api/v1/__init__.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from src.handlers.api.v1.auth import auth_router +from src.handlers.api.v1.buckets import buckets_router +from src.handlers.api.v1.files import files_router +from src.handlers.api.v1.folders import folders_router +from src.handlers.api.v1.health import health_router +from src.handlers.api.v1.public_links import public_links_router +from src.handlers.api.v1.search import search_router +from src.handlers.api.v1.upload_sessions import upload_sessions_router +from src.handlers.api.v1.users import users_router + +v1_router = APIRouter(prefix='/v1') + +v1_router.include_router(auth_router) +v1_router.include_router(buckets_router) +v1_router.include_router(files_router) +v1_router.include_router(folders_router) +v1_router.include_router(health_router) +v1_router.include_router(public_links_router) +v1_router.include_router(search_router) +v1_router.include_router(upload_sessions_router) +v1_router.include_router(users_router) diff --git a/src/handlers/api/v1/auth/__init__.py b/src/handlers/api/v1/auth/__init__.py new file mode 100644 index 0000000..caf83ee --- /dev/null +++ b/src/handlers/api/v1/auth/__init__.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from src.handlers.api.v1.auth.login.router import login_router +from src.handlers.api.v1.auth.refresh.router import refresh_router +from src.handlers.api.v1.auth.register.router import register_router +from src.handlers.api.v1.auth.reset_password.router import reset_password_router +from src.handlers.api.v1.auth.two_fa_auth.router import two_fa_router + +auth_router = APIRouter(prefix="/auth", tags=["auth"]) + +auth_router.include_router(login_router) +auth_router.include_router(register_router) +auth_router.include_router(reset_password_router) +auth_router.include_router(two_fa_router) +auth_router.include_router(refresh_router) diff --git a/src/handlers/api/v1/auth/login/__init__.py b/src/handlers/api/v1/auth/login/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/auth/login/models.py b/src/handlers/api/v1/auth/login/models.py new file mode 100644 index 0000000..4fd5c83 --- /dev/null +++ b/src/handlers/api/v1/auth/login/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=6) + +class LoginResponse(BaseModel): + ... diff --git a/src/handlers/api/v1/auth/login/router.py b/src/handlers/api/v1/auth/login/router.py new file mode 100644 index 0000000..31ffc9c --- /dev/null +++ b/src/handlers/api/v1/auth/login/router.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.handlers.api.v1.auth.login.models import LoginRequest, LoginResponse +from src.services.auth.service import AuthService + +login_router = APIRouter(prefix='/login') + +@login_router.post('/', response_model=LoginResponse) +async def login( + payload: LoginRequest, + service: Annotated[AuthService, Depends()], +) -> LoginResponse: + await service.start_login( + email=str(payload.email), + password=payload.password, + ) + + return LoginResponse() \ No newline at end of file diff --git a/src/handlers/api/v1/auth/refresh/__init__.py b/src/handlers/api/v1/auth/refresh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/auth/refresh/models.py b/src/handlers/api/v1/auth/refresh/models.py new file mode 100644 index 0000000..eac0e11 --- /dev/null +++ b/src/handlers/api/v1/auth/refresh/models.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + +from src.services.auth.models import TokenResponse + + +class RefreshRequest(BaseModel): + refresh_token: str = Field(..., alias="refreshToken") + +class RefreshResponse(TokenResponse): + ... \ No newline at end of file diff --git a/src/handlers/api/v1/auth/refresh/router.py b/src/handlers/api/v1/auth/refresh/router.py new file mode 100644 index 0000000..83482de --- /dev/null +++ b/src/handlers/api/v1/auth/refresh/router.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.handlers.api.v1.auth.refresh.models import RefreshRequest, RefreshResponse +from src.services.auth.service import AuthService + +refresh_router = APIRouter(prefix='/refresh') + +@refresh_router.post('/', response_model=RefreshResponse) +async def refresh( + payload: RefreshRequest, + service: Annotated[AuthService, Depends(AuthService)], +) -> RefreshResponse: + tokens = await service.refresh_tokens( + refresh_token=payload.refresh_token, + ) + + return RefreshResponse( + accessToken=tokens.access.token, + refreshToken=tokens.refresh.token + ) \ No newline at end of file diff --git a/src/handlers/api/v1/auth/register/__init__.py b/src/handlers/api/v1/auth/register/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/auth/register/models.py b/src/handlers/api/v1/auth/register/models.py new file mode 100644 index 0000000..742d723 --- /dev/null +++ b/src/handlers/api/v1/auth/register/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=6) + +class RegisterResponse(BaseModel): + ... diff --git a/src/handlers/api/v1/auth/register/router.py b/src/handlers/api/v1/auth/register/router.py new file mode 100644 index 0000000..b086299 --- /dev/null +++ b/src/handlers/api/v1/auth/register/router.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.handlers.api.v1.auth.register.models import RegisterResponse, RegisterRequest +from src.services.auth.service import AuthService + +register_router = APIRouter(prefix='/register') + +@register_router.post('/', response_model=RegisterResponse) +async def register( + payload: RegisterRequest, + service: Annotated[AuthService, Depends()] +) -> RegisterResponse: + await service.start_registration( + email=str(payload.email), + password=payload.password, + ) + + return RegisterResponse() \ No newline at end of file diff --git a/src/handlers/api/v1/auth/reset_password/__init__.py b/src/handlers/api/v1/auth/reset_password/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/auth/reset_password/models.py b/src/handlers/api/v1/auth/reset_password/models.py new file mode 100644 index 0000000..301728c --- /dev/null +++ b/src/handlers/api/v1/auth/reset_password/models.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class RestorePasswordRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + email: EmailStr + new_password: str = Field(..., alias='newPassword', min_length=6) + +class RestorePasswordResponse(BaseModel): + ... \ No newline at end of file diff --git a/src/handlers/api/v1/auth/reset_password/router.py b/src/handlers/api/v1/auth/reset_password/router.py new file mode 100644 index 0000000..1eb9309 --- /dev/null +++ b/src/handlers/api/v1/auth/reset_password/router.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.handlers.api.v1.auth.reset_password.models import RestorePasswordResponse, RestorePasswordRequest +from src.services.auth.service import AuthService + +reset_password_router = APIRouter(prefix='/restore') + +@reset_password_router.post('/', response_model=RestorePasswordResponse) +async def restore( + payload: RestorePasswordRequest, + service: Annotated[AuthService, Depends()] +) -> RestorePasswordResponse: + await service.start_password_reset( + email=str(payload.email), + new_password=payload.new_password, + ) + + return RestorePasswordResponse() \ No newline at end of file diff --git a/src/handlers/api/v1/auth/two_fa_auth/__init__.py b/src/handlers/api/v1/auth/two_fa_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/auth/two_fa_auth/models.py b/src/handlers/api/v1/auth/two_fa_auth/models.py new file mode 100644 index 0000000..437b17c --- /dev/null +++ b/src/handlers/api/v1/auth/two_fa_auth/models.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, EmailStr, Field + +from src.services.auth.models import TokenResponse + + +class TwoFaRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=6) + code: str = Field(..., min_length=6, max_length=6) + +class TwoFaResponse(TokenResponse): + ... \ No newline at end of file diff --git a/src/handlers/api/v1/auth/two_fa_auth/router.py b/src/handlers/api/v1/auth/two_fa_auth/router.py new file mode 100644 index 0000000..9eef760 --- /dev/null +++ b/src/handlers/api/v1/auth/two_fa_auth/router.py @@ -0,0 +1,25 @@ +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends + +from src.handlers.api.v1.auth.two_fa_auth.models import TwoFaRequest, TwoFaResponse +from src.services.auth.service import AuthService + +two_fa_router = APIRouter(prefix='/2fa') + +@two_fa_router.post('/{operation}', response_model=TwoFaResponse) +async def two_fa( + payload: TwoFaRequest, + operation: Literal['register', 'login', 'reset_password'], + service: Annotated[AuthService, Depends()], +) -> TwoFaResponse: + tokens = await service.complete_operation( + operation=operation, + email=str(payload.email), + password=payload.password, + code=payload.code + ) + return TwoFaResponse( + accessToken=tokens.access.token, + refreshToken=tokens.refresh.token + ) \ No newline at end of file diff --git a/src/handlers/api/v1/buckets/__init__.py b/src/handlers/api/v1/buckets/__init__.py new file mode 100644 index 0000000..966ce57 --- /dev/null +++ b/src/handlers/api/v1/buckets/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.buckets.router import buckets_router + + +__all__ = [ + 'buckets_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/buckets/models.py b/src/handlers/api/v1/buckets/models.py new file mode 100644 index 0000000..7b5922c --- /dev/null +++ b/src/handlers/api/v1/buckets/models.py @@ -0,0 +1,112 @@ +from uuid import UUID + +from pydantic import BaseModel, RootModel, Field, EmailStr, model_validator, ValidationError + +from src.database.repository.postgres.bucket_permission.dtos import PermissionType, BucketPermission +from src.database.repository.postgres.bucket.dtos import Bucket +from src.services.buckets.models import UserBrief + + +class CreateBucketsRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + is_public: bool = Field(..., alias='isPublic') + + +class UpdateBucketsRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + is_public: bool = Field(..., alias='isPublic') + + +class BaseBucketsResponse(BaseModel): + id: UUID + name: str + isPublic: bool + permission: PermissionType | None = None + filesCount: int = 0 + size: int = 0 + + @classmethod + def from_bucket(cls, bucket: 'Bucket') -> 'BaseBucketsResponse': + return cls( + id=bucket.id, + name=bucket.name, + isPublic=bucket.is_public, + permission=bucket.permission, + filesCount=bucket.files_count, + size=bucket.size, + ) + + +class GetBucketsResponse(RootModel[list[BaseBucketsResponse]]): ... + + +# PERMISSIONS +class GrantBucketPermissionRequest(BaseModel): + email: EmailStr | None = None + user_id: UUID | None = None + permission: PermissionType + + @model_validator(mode='after') + def validate(self): + if not self.email and not self.user_id: + raise ValidationError('Either email or user_id must be provided') + if self.email and self.user_id: + raise ValidationError('Email and user_id are mutually exclusive') + return self + + +class GrantBucketPermissionResponse(BaseModel): + user_id: UUID + permission: PermissionType + + @classmethod + def from_permission(cls, perm: 'BucketPermission') -> 'GrantBucketPermissionResponse': + return cls( + user_id=perm.user_id, + permission=perm.permission_type, + ) + + +class UpdateBucketPermissionRequest(BaseModel): + email: EmailStr | None = None + user_id: UUID | None = None + permission: PermissionType + + @model_validator(mode='after') + def validate(self): + if not self.email and not self.user_id: + raise ValidationError('Either email or user_id must be provided') + if self.email and self.user_id: + raise ValidationError('Email and user_id are mutually exclusive') + return self + + +class UpdateBucketPermissionResponse(BaseModel): + user_id: UUID + permission: PermissionType + + @classmethod + def from_permission(cls, perm: BucketPermission) -> 'UpdateBucketPermissionResponse': + return cls( + user_id=perm.user_id, + permission=perm.permission_type, + ) + + +class DeleteBucketPermissionRequest(BaseModel): + email: EmailStr | None = None + user_id: UUID | None = None + + @model_validator(mode='after') + def validate(self): + if not self.email and not self.user_id: + raise ValidationError('Either email or user_id must be provided') + if self.email and self.user_id: + raise ValidationError('Email and user_id are mutually exclusive') + return self + + +class DeleteBucketPermissionResponse(BaseModel): ... + + +class GetUsersWithPermissionResponse(RootModel[list[UserBrief]]): ... diff --git a/src/handlers/api/v1/buckets/router.py b/src/handlers/api/v1/buckets/router.py new file mode 100644 index 0000000..472f8c2 --- /dev/null +++ b/src/handlers/api/v1/buckets/router.py @@ -0,0 +1,158 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends + + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.buckets.models import ( + GetBucketsResponse, + BaseBucketsResponse, + CreateBucketsRequest, + UpdateBucketsRequest, + GrantBucketPermissionResponse, + GrantBucketPermissionRequest, + UpdateBucketPermissionResponse, + UpdateBucketPermissionRequest, + DeleteBucketPermissionResponse, + DeleteBucketPermissionRequest, + GetUsersWithPermissionResponse, +) +from src.handlers.dependencies.auth import get_current_user +from src.services.buckets.service import BucketsService + +buckets_router = APIRouter(prefix='/buckets', tags=['buckets']) + + +@buckets_router.get('/', response_model=GetBucketsResponse) +async def get_buckets( + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> GetBucketsResponse: + buckets = await service.get_buckets(user_id=user.id) + return GetBucketsResponse.model_validate([BaseBucketsResponse.from_bucket(bucket) for bucket in buckets]) + + +@buckets_router.post('/', response_model=BaseBucketsResponse) +async def create_bucket( + payload: CreateBucketsRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> BaseBucketsResponse: + bucket = await service.create_bucket( + user_id=user.id, + name=payload.name, + is_public=payload.is_public, + ) + + return BaseBucketsResponse.from_bucket(bucket) + + +@buckets_router.get('/{bucketId}') +async def get_bucket( + bucketId: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +): + bucket = await service.get_bucket( + user_id=user.id, + bucket_id=bucketId, + ) + + return BaseBucketsResponse.from_bucket(bucket) + + +@buckets_router.put('/{bucketId}') +async def update_bucket( + bucketId: UUID, + payload: UpdateBucketsRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +): + bucket = await service.update_bucket( + user_id=user.id, + bucket_id=bucketId, + name=payload.name, + is_public=payload.is_public, + ) + + return BaseBucketsResponse.from_bucket(bucket) + + +@buckets_router.delete('/{bucketId}') +async def delete_bucket( + bucketId: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +): + return await service.delete_bucket( + user_id=user.id, + bucket_id=bucketId, + ) + + +@buckets_router.get('/{bucketId}/users', response_model=GetUsersWithPermissionResponse) +async def get_bucket_users( + bucketId: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> GetUsersWithPermissionResponse: + users = await service.get_bucket_users( + actor_user_id=user.id, + bucket_id=bucketId, + ) + + return GetUsersWithPermissionResponse.model_validate(users) + + +@buckets_router.post('/{bucketId}/permissions', response_model=GrantBucketPermissionResponse) +async def grant_bucket_permissions( + bucketId: UUID, + payload: GrantBucketPermissionRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> GrantBucketPermissionResponse: + permission = await service.grant_permission( + actor_user_id=user.id, + bucket_id=bucketId, + email=payload.email, + target_user_id=payload.user_id, + permission=payload.permission, + ) + + return GrantBucketPermissionResponse.from_permission(permission) + + +@buckets_router.put('/{bucketId}/permissions', response_model=UpdateBucketPermissionResponse) +async def update_bucket_permissions( + bucketId: UUID, + payload: UpdateBucketPermissionRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> UpdateBucketPermissionResponse: + permission = await service.update_permission( + actor_user_id=user.id, + bucket_id=bucketId, + email=payload.email, + target_user_id=payload.user_id, + permission=payload.permission, + ) + + return UpdateBucketPermissionResponse.from_permission(permission) + + +@buckets_router.delete('/{bucketId}/permissions', response_model=DeleteBucketPermissionResponse) +async def delete_bucket_permissions( + bucketId: UUID, + payload: DeleteBucketPermissionRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[BucketsService, Depends(BucketsService)], +) -> DeleteBucketPermissionResponse: + await service.delete_permission( + actor_user_id=user.id, + bucket_id=bucketId, + email=payload.email, + target_user_id=payload.user_id, + ) + + return DeleteBucketPermissionResponse() diff --git a/src/handlers/api/v1/files/__init__.py b/src/handlers/api/v1/files/__init__.py new file mode 100644 index 0000000..5ec728e --- /dev/null +++ b/src/handlers/api/v1/files/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.files.router import files_router + + +__all__ = [ + 'files_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/files/models.py b/src/handlers/api/v1/files/models.py new file mode 100644 index 0000000..b90ec34 --- /dev/null +++ b/src/handlers/api/v1/files/models.py @@ -0,0 +1,71 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, RootModel + +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.file.dtos import File + + +class BaseFilesResponse(BaseModel): + id: UUID + name: str + bucketId: UUID + folderId: UUID | None + size: int + mimeType: str + uploadedAt: datetime + permission: PermissionType | None = None + + @classmethod + def from_file(cls, file: File) -> 'BaseFilesResponse': + return cls( + id=file.id, + name=file.original_filename, + bucketId=file.bucket_id, + folderId=file.folder_id, + size=file.file_size_bytes, + mimeType=file.mime_type or 'application/octet-stream', + uploadedAt=file.created_at, + permission=file.permission, + ) + + +class GetFilesResponse(RootModel[list[BaseFilesResponse]]): + pass + + +class GetFileMetadataResponse(BaseFilesResponse): + pass + + +class RenameOrMoveFileRequest(BaseModel): + name: str | None = None + folderId: str | None = None + + @property + def folder_id(self) -> UUID | None: + if self.folderId is not None and self.folderId != '': + return UUID(self.folderId) + return None + + @property + def move_to_root(self) -> bool: + return self.folderId == '' + + +class RenameOrMoveFileResponse(BaseFilesResponse): + pass + + +class DeleteFileResponse(BaseModel): + pass + + +class GetRecentFilesResponse(RootModel[list[BaseFilesResponse]]): + pass + + +class GetDownloadFileResponse(BaseModel): + downloadUrl: str + expiresAt: datetime diff --git a/src/handlers/api/v1/files/router.py b/src/handlers/api/v1/files/router.py new file mode 100644 index 0000000..e9849f6 --- /dev/null +++ b/src/handlers/api/v1/files/router.py @@ -0,0 +1,108 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.files.models import ( + GetFileMetadataResponse, + RenameOrMoveFileRequest, + RenameOrMoveFileResponse, + DeleteFileResponse, + GetDownloadFileResponse, + GetRecentFilesResponse, + GetFilesResponse, + BaseFilesResponse, +) +from src.handlers.dependencies.auth import get_current_user +from src.services.files.service import FilesService + +files_router = APIRouter(prefix='/files', tags=['files']) + + +@files_router.get('/', response_model=GetFilesResponse) +async def get_files( + service: Annotated[FilesService, Depends(FilesService)], + user: Annotated[User, Depends(get_current_user)], + bucket_id: UUID = Query(..., alias='bucketId'), # noqa: B008 + parent_id: UUID | None = Query(None, alias='parentId'), # noqa: B008 +) -> GetFilesResponse: + files = await service.get_files( + user_id=user.id, + bucket_id=bucket_id, + folder_id=parent_id, + ) + + return GetFilesResponse.model_validate([BaseFilesResponse.from_file(f) for f in files]) + + +@files_router.get('/recent', response_model=GetRecentFilesResponse) +async def get_recent_files( + user: Annotated[User, Depends(get_current_user)], + service: Annotated[FilesService, Depends(FilesService)], + limit: int = Query(20, ge=1, le=50), +) -> GetRecentFilesResponse: + files = await service.get_recent_files( + user_id=user.id, + limit=limit, + ) + return GetRecentFilesResponse.model_validate([BaseFilesResponse.from_file(f) for f in files]) + + +@files_router.get('/{fileId}', response_model=GetFileMetadataResponse) +async def get_file_metadata( + fileId: UUID, + service: Annotated[FilesService, Depends(FilesService)], + user: Annotated[User, Depends(get_current_user)], +) -> GetFileMetadataResponse: + file = await service.get_file_metadata( + file_id=fileId, + user_id=user.id, + ) + + return GetFileMetadataResponse.from_file(file) + + +@files_router.patch('/{fileId}', response_model=RenameOrMoveFileResponse) +async def rename_or_move_file( + fileId: UUID, + payload: RenameOrMoveFileRequest, + service: Annotated[FilesService, Depends(FilesService)], + user: Annotated[User, Depends(get_current_user)], +) -> RenameOrMoveFileResponse: + file = await service.rename_or_move_file( + file_id=fileId, + user_id=user.id, + new_name=payload.name, + new_folder_id=payload.folder_id, + move_to_root=payload.move_to_root, + ) + + return RenameOrMoveFileResponse.from_file(file) + + +@files_router.delete('/{fileId}', response_model=DeleteFileResponse) +async def delete_file_metadata( + fileId: UUID, + service: Annotated[FilesService, Depends(FilesService)], + user: Annotated[User, Depends(get_current_user)], +) -> DeleteFileResponse: + await service.delete_file(file_id=fileId, user_id=user.id) + return DeleteFileResponse() + + +@files_router.get('/{fileId}/download', response_model=GetDownloadFileResponse) +async def get_download_link( + fileId: UUID, + service: Annotated[FilesService, Depends(FilesService)], + user: Annotated[User, Depends(get_current_user)], +) -> GetDownloadFileResponse: + url, expires_at = await service.get_download_link( + file_id=fileId, + user_id=user.id, + ) + + return GetDownloadFileResponse( + downloadUrl=url, + expiresAt=expires_at, + ) diff --git a/src/handlers/api/v1/folders/__init__.py b/src/handlers/api/v1/folders/__init__.py new file mode 100644 index 0000000..bc40a13 --- /dev/null +++ b/src/handlers/api/v1/folders/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.folders.router import folders_router + + +__all__ = [ + 'folders_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/folders/models.py b/src/handlers/api/v1/folders/models.py new file mode 100644 index 0000000..f6f78ed --- /dev/null +++ b/src/handlers/api/v1/folders/models.py @@ -0,0 +1,64 @@ +from uuid import UUID + +from pydantic import BaseModel, RootModel + +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.folder.dtos import Folder + + +class BaseFolderResponse(BaseModel): + id: UUID + name: str + depth: int + bucketId: UUID + parentId: UUID | None + permission: PermissionType | None = None + + @classmethod + def from_folder(cls, folder: Folder) -> 'BaseFolderResponse': + return cls( + id=folder.id, + name=folder.name, + depth=folder.depth, + bucketId=folder.bucket_id, + parentId=folder.parent_id, + permission=folder.permission, + ) + + +class GetFoldersResponse(RootModel[list[BaseFolderResponse]]): + pass + + +class GetFolderInfoResponse(BaseFolderResponse): + pass + + +class CreateFolderRequest(BaseModel): + name: str + bucketId: UUID + parentId: UUID | None + + +class CreateFolderResponse(BaseFolderResponse): + pass + + +class RenameFolderRequest(BaseModel): + name: str + + +class RenameFolderResponse(BaseFolderResponse): + pass + + +class MoveFolderRequest(BaseModel): + parentId: UUID | None + + +class MoveFolderResponse(BaseFolderResponse): + pass + + +class DeleteFolderResponse(BaseModel): + pass diff --git a/src/handlers/api/v1/folders/router.py b/src/handlers/api/v1/folders/router.py new file mode 100644 index 0000000..3042b09 --- /dev/null +++ b/src/handlers/api/v1/folders/router.py @@ -0,0 +1,108 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.folders.models import ( + GetFolderInfoResponse, + CreateFolderResponse, + CreateFolderRequest, + RenameFolderResponse, + RenameFolderRequest, + MoveFolderResponse, + MoveFolderRequest, + DeleteFolderResponse, + GetFoldersResponse, + BaseFolderResponse, +) +from src.handlers.dependencies.auth import get_current_user +from src.services.folders.service import FoldersService + +folders_router = APIRouter(prefix='/folders', tags=['folders']) + + +@folders_router.get('/', response_model=GetFoldersResponse) +async def get_folders( + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], + bucket_id: UUID = Query(..., alias='bucketId'), # noqa: B008 + parent_id: UUID | None = Query(None, alias='parentId'), # noqa: B008 +) -> GetFoldersResponse: + folders = await service.get_folders( + user_id=user.id, + bucket_id=bucket_id, + parent_id=parent_id, + ) + + return GetFoldersResponse.model_validate([BaseFolderResponse.from_folder(f) for f in folders]) + + +@folders_router.get('/{folderId}', response_model=GetFolderInfoResponse) +async def get_folder_info( + folderId: UUID, + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], +) -> GetFolderInfoResponse: + folder = await service.get_folder_info( + folder_id=folderId, + user_id=user.id, + ) + + return GetFolderInfoResponse.from_folder(folder) + + +@folders_router.post('/', response_model=CreateFolderResponse) +async def create_folder( + payload: CreateFolderRequest, + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], +) -> CreateFolderResponse: + folder = await service.create_folder( + name=payload.name, bucket_id=payload.bucketId, parent_id=payload.parentId, user_id=user.id + ) + + return CreateFolderResponse.from_folder(folder) + + +@folders_router.patch('/{folderId}', response_model=RenameFolderResponse) +async def rename_folder( + folderId: UUID, + payload: RenameFolderRequest, + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], +) -> RenameFolderResponse: + folder = await service.rename_folder( + folder_id=folderId, + new_name=payload.name, + user_id=user.id, + ) + + return RenameFolderResponse.from_folder(folder) + + +@folders_router.post('/{folderId}/move', response_model=MoveFolderResponse) +async def move_folder( + folderId: UUID, + payload: MoveFolderRequest, + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], +) -> MoveFolderResponse: + folder = await service.move_folder( + folder_id=folderId, + new_parent_id=payload.parentId, + user_id=user.id, + ) + + return MoveFolderResponse.from_folder(folder) + + +@folders_router.delete('/{folderId}', response_model=DeleteFolderResponse) +async def delete_folder( + folderId: UUID, + service: Annotated[FoldersService, Depends(FoldersService)], + user: Annotated[User, Depends(get_current_user)], +) -> DeleteFolderResponse: + await service.delete_folder(folder_id=folderId, user_id=user.id) + + return DeleteFolderResponse() diff --git a/src/handlers/api/v1/health/__init__.py b/src/handlers/api/v1/health/__init__.py new file mode 100644 index 0000000..8c4dd9c --- /dev/null +++ b/src/handlers/api/v1/health/__init__.py @@ -0,0 +1,7 @@ +from src.handlers.api.v1.health.router import health_router + + +__all__ = [ + 'health_router', +] + diff --git a/src/handlers/api/v1/health/router.py b/src/handlers/api/v1/health/router.py new file mode 100644 index 0000000..87bcc0b --- /dev/null +++ b/src/handlers/api/v1/health/router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +health_router = APIRouter(prefix="/health", tags=["health"]) + +@health_router.get("") +async def health(): + return {"status": "ok"} \ No newline at end of file diff --git a/src/handlers/api/v1/public_links/__init__.py b/src/handlers/api/v1/public_links/__init__.py new file mode 100644 index 0000000..71f2a8f --- /dev/null +++ b/src/handlers/api/v1/public_links/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.public_links.router import public_links_router + + +__all__ = [ + 'public_links_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/public_links/models.py b/src/handlers/api/v1/public_links/models.py new file mode 100644 index 0000000..aafab59 --- /dev/null +++ b/src/handlers/api/v1/public_links/models.py @@ -0,0 +1,59 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator, RootModel + +SECONDS_IN_MONTH = 2_592_000 + + +class BasePublicLinkResponse(BaseModel): + id: UUID + url: str | None = Field(None, alias='url') + file_id: UUID | None = Field(None, alias='fileId') + folder_id: UUID | None = Field(None, alias='folderId') + expires_at: datetime | None = Field(None, alias='expiresAt') + max_downloads: int | None = Field(None, alias='maxDownloads') + downloads_count: int = Field(..., alias='downloadsCount') + created_at: datetime = Field(..., alias='createdAt') + updated_at: datetime = Field(..., alias='updatedAt') + + class Config: + from_attributes = True + populate_by_name = True + + +class CreatePublicLinkRequest(BaseModel): + file_id: UUID | None = Field(None, alias='fileId') + folder_id: UUID | None = Field(None, alias='folderId') + expires_in_seconds: int | None = Field(None, alias='expiresInSeconds', ge=60, le=SECONDS_IN_MONTH) + max_downloads: int | None = Field(None, alias='maxDownloads', ge=1, le=1000) + + @model_validator(mode='after') + def check_one_resource(self): + if (self.file_id is None) == (self.folder_id is None): + raise ValueError('Exactly one of file_id or folder_id must be provided') + return self + + +class CreatePublicLinkResponse(BasePublicLinkResponse): + pass + + +class PublicLinkListResponse(RootModel[list[BasePublicLinkResponse]]): + pass + + +class PublicLinkDetailResponse(BasePublicLinkResponse): + pass + + +class DeletePublicLinkResponse(BaseModel): + pass + + +class PublicDownloadInfoResponse(BaseModel): + id: UUID + file_name: str | None = Field(None, alias='fileName') + downloads_count: int = Field(..., alias='downloadsCount') + max_downloads: int | None = Field(None, alias='maxDownloads') + expires_at: datetime | None = Field(None, alias='expiresAt') diff --git a/src/handlers/api/v1/public_links/router.py b/src/handlers/api/v1/public_links/router.py new file mode 100644 index 0000000..cf2ab66 --- /dev/null +++ b/src/handlers/api/v1/public_links/router.py @@ -0,0 +1,90 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import ValidationError + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.public_links.models import ( + CreatePublicLinkResponse, + CreatePublicLinkRequest, + PublicLinkListResponse, + PublicLinkDetailResponse, + DeletePublicLinkResponse, + PublicDownloadInfoResponse, +) +from src.handlers.dependencies.auth import get_current_user +from src.services.public_link.service import PublicLinkService + +public_links_router = APIRouter(prefix='/public-links', tags=['public-links']) + + +@public_links_router.post('', response_model=CreatePublicLinkResponse) +async def create_public_link( + payload: CreatePublicLinkRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[PublicLinkService, Depends(PublicLinkService)], +) -> CreatePublicLinkResponse: + link = await service.create_link_with_url( + actor_user_id=user.id, + file_id=payload.file_id, + folder_id=payload.folder_id, + expires_in_seconds=payload.expires_in_seconds, + max_downloads=payload.max_downloads, + ) + return CreatePublicLinkResponse(**link) + + +@public_links_router.get('', response_model=PublicLinkListResponse) +async def list_public_links( + user: Annotated[User, Depends(get_current_user)], + service: Annotated[PublicLinkService, Depends(PublicLinkService)], + file_id: UUID | None = Query(None, alias='fileId'), # noqa: B008 + folder_id: UUID | None = Query(None, alias='folderId'), # noqa: B008 +) -> PublicLinkListResponse: + if (file_id is None) == (folder_id is None): + raise ValidationError('Exactly one of file_id or folder_id must be provided') + + links = await service.list_links_with_urls( + actor_user_id=user.id, + file_id=file_id, + folder_id=folder_id, + ) + return PublicLinkListResponse(links) + + +@public_links_router.get('/{link_id}', response_model=PublicLinkDetailResponse) +async def get_public_link( + link_id: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[PublicLinkService, Depends(PublicLinkService)], +) -> PublicLinkDetailResponse: + link = await service.get_link_with_url(actor_user_id=user.id, link_id=link_id) + return PublicLinkDetailResponse(**link) + + +@public_links_router.delete('/{link_id}', response_model=DeletePublicLinkResponse) +async def delete_public_link( + link_id: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[PublicLinkService, Depends(PublicLinkService)], +) -> DeletePublicLinkResponse: + await service.delete_link(actor_user_id=user.id, link_id=link_id) + return DeletePublicLinkResponse() + + +@public_links_router.get('/{link_id}/info', response_model=PublicDownloadInfoResponse) +async def get_public_link_info( + link_id: UUID, + service: Annotated[PublicLinkService, Depends(PublicLinkService)], +): + return await service.get_public_link_info(link_id) + + +@public_links_router.get('/{link_id}/download') +async def get_public_download_url( + link_id: UUID, + service: Annotated[PublicLinkService, Depends(PublicLinkService)], +): + url = await service.get_public_download_url(link_id) + return {'url': url} diff --git a/src/handlers/api/v1/search/__init__.py b/src/handlers/api/v1/search/__init__.py new file mode 100644 index 0000000..285563d --- /dev/null +++ b/src/handlers/api/v1/search/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.search.router import search_router + + +__all__ = [ + 'search_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/search/models.py b/src/handlers/api/v1/search/models.py new file mode 100644 index 0000000..21cee8e --- /dev/null +++ b/src/handlers/api/v1/search/models.py @@ -0,0 +1,38 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + + +class BaseBucketsResponse(BaseModel): + id: UUID + name: str + is_public: bool = Field(..., alias='isPublic') + files_count: int = Field(0, alias='filesCount') + size: int = Field(0, alias='size') + + +class BaseFolderResponse(BaseModel): + id: UUID + name: str + depth: int + bucket_id: UUID = Field(..., alias='bucketId') + parent_id: UUID | None = Field(..., alias='parentId') + + +class BaseFilesResponse(BaseModel): + id: UUID + name: str + + bucket_id: str = Field(..., alias='bucketId') + folder_id: str = Field(..., alias='folderId') + + size: int + mime_type: str = Field(..., alias='mimeType') + uploaded_at: datetime = Field(..., alias='uploadedAt') + + +class SearchResponse(BaseModel): + buckets: list[BaseBucketsResponse] + folders: list[BaseFolderResponse] + files: list[BaseFilesResponse] diff --git a/src/handlers/api/v1/search/router.py b/src/handlers/api/v1/search/router.py new file mode 100644 index 0000000..0a11c7d --- /dev/null +++ b/src/handlers/api/v1/search/router.py @@ -0,0 +1,62 @@ +from typing import Literal, Annotated +from uuid import UUID + +from fastapi import APIRouter, Query, Depends + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.search.models import SearchResponse, BaseBucketsResponse, BaseFilesResponse, BaseFolderResponse +from src.handlers.dependencies.auth import get_current_user +from src.services.search.service import SearchService + +search_router = APIRouter(prefix='/search', tags=['search']) + + +@search_router.get('', response_model=SearchResponse) +async def global_search( + user: Annotated[User, Depends(get_current_user)], + service: Annotated[SearchService, Depends(SearchService)], + query: str = Query(..., description='Search query', min_length=1), + search_type: Literal['bucket', 'folder', 'file'] | None = Query(None, alias='type', description='Filter by type'), + bucket_id: UUID | None = Query(None, alias='bucketId', description='Limit search to specific bucket'), # noqa: B008 +) -> SearchResponse: + buckets, folders, files = await service.search( + user_id=user.id, + query=query, + search_type=search_type, + bucket_id=bucket_id, + ) + + return SearchResponse( + buckets=[ + BaseBucketsResponse( + id=b.id, + name=b.name, + isPublic=b.is_public, + filesCount=b.files_count, + size=b.size, + ) + for b in buckets + ], + folders=[ + BaseFolderResponse( + id=f.id, + name=f.name, + depth=f.depth, + bucketId=f.bucket_id, + parentId=f.parent_id, + ) + for f in folders + ], + files=[ + BaseFilesResponse( + id=f.id, + name=f.original_filename, + bucketId=str(f.bucket_id), + folderId=str(f.folder_id) if f.folder_id else '', + size=f.file_size_bytes, + mimeType=f.mime_type or '', + uploadedAt=f.created_at, + ) + for f in files + ], + ) diff --git a/src/handlers/api/v1/upload_sessions/__init__.py b/src/handlers/api/v1/upload_sessions/__init__.py new file mode 100644 index 0000000..80fc14a --- /dev/null +++ b/src/handlers/api/v1/upload_sessions/__init__.py @@ -0,0 +1,6 @@ +from src.handlers.api.v1.upload_sessions.router import upload_sessions_router + + +__all__ = [ + 'upload_sessions_router', +] \ No newline at end of file diff --git a/src/handlers/api/v1/upload_sessions/models.py b/src/handlers/api/v1/upload_sessions/models.py new file mode 100644 index 0000000..f87eb72 --- /dev/null +++ b/src/handlers/api/v1/upload_sessions/models.py @@ -0,0 +1,76 @@ +from typing import Literal +from uuid import UUID +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + + +class InitUploadRequest(BaseModel): + model_config = ConfigDict(populate_by_name=False) + + bucket_id: UUID = Field(..., alias="bucketId") + folder_id: UUID | None = Field(None, alias="folderId") + name: str = Field(..., min_length=1, max_length=255) + size: int = Field(..., ge=1, description="File size in bytes") + mime_type: str = Field(..., alias="mimeType") + + +class SimpleUploadResponse(BaseModel): + model_config = ConfigDict(populate_by_name=False) + + session_id: UUID = Field(..., alias="sessionId") + upload_type: Literal["simple"] = Field(..., alias="uploadType") + upload_url: str = Field(..., alias="uploadUrl") + expires_at: datetime = Field(..., alias="expiresAt") + + +class MultipartUploadResponse(BaseModel): + model_config = ConfigDict(populate_by_name=False) + + session_id: UUID = Field(..., alias="sessionId") + upload_type: Literal["multipart"] = Field(..., alias="uploadType") + part_urls: dict[int, str] = Field(..., alias="partUrls") + chunk_size: int = Field(..., alias="chunkSize") + total_parts: int = Field(..., alias="totalParts") + expires_at: datetime = Field(..., alias="expiresAt") + + +InitUploadResponse = SimpleUploadResponse | MultipartUploadResponse + + +class PartCompleteRequest(BaseModel): + model_config = ConfigDict(populate_by_name=False) + + part_number: int = Field(..., alias="partNumber", ge=1) + etag: str = Field(..., min_length=1) + +class PartCompleteResponse(BaseModel): + status: Literal['ok'] = 'ok' + +class UploadStatusResponse(BaseModel): + model_config = ConfigDict(populate_by_name=False) + + session_id: UUID = Field(..., alias="sessionId") + upload_type: Literal["simple", "multipart"] = Field(..., alias="uploadType") + status: Literal["pending", "active", "completed", "aborted"] + completed_parts: int | None = Field(None, alias="completedParts") + total_parts: int | None = Field(None, alias="totalParts") + expires_at: datetime = Field(..., alias="expiresAt") + + +class CompleteUploadResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=False, + from_attributes=True, + ) + + file_id: UUID = Field(..., alias="fileId") + name: str + size: int + mime_type: str = Field(..., alias="mimeType") + bucket_id: UUID = Field(..., alias="bucketId") + folder_id: UUID | None = Field(None, alias="folderId") + uploaded_at: datetime = Field(..., alias="uploadedAt") + + +class AbortUploadResponse(BaseModel): + pass \ No newline at end of file diff --git a/src/handlers/api/v1/upload_sessions/router.py b/src/handlers/api/v1/upload_sessions/router.py new file mode 100644 index 0000000..e35284b --- /dev/null +++ b/src/handlers/api/v1/upload_sessions/router.py @@ -0,0 +1,78 @@ +from uuid import UUID +from typing import Annotated +from fastapi import APIRouter, Depends + + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.upload_sessions.models import ( + InitUploadRequest, + InitUploadResponse, + PartCompleteRequest, + UploadStatusResponse, + CompleteUploadResponse, + AbortUploadResponse, PartCompleteResponse, +) +from src.handlers.dependencies.auth import get_current_user +from src.services.upload_sessions.service import UploadSessionsService + +upload_sessions_router = APIRouter(prefix="/upload-sessions", tags=["upload"]) + + +@upload_sessions_router.post("/init", response_model=InitUploadResponse) +async def init_upload( + payload: InitUploadRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[UploadSessionsService, Depends(UploadSessionsService)], +) -> InitUploadResponse: + return await service.init_upload( + actor_user_id=user.id, + bucket_id=payload.bucket_id, + folder_id=payload.folder_id, + name=payload.name, + size=payload.size, + mime_type=payload.mime_type, + ) + + +@upload_sessions_router.post("/{session_id}/parts", response_model=PartCompleteResponse) +async def complete_part( + session_id: UUID, + payload: PartCompleteRequest, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[UploadSessionsService, Depends(UploadSessionsService)], +) -> PartCompleteResponse: + await service.complete_part( + actor_user_id=user.id, + session_id=session_id, + part_number=payload.part_number, + etag=payload.etag, + ) + return PartCompleteResponse() + + +@upload_sessions_router.get("/{session_id}", response_model=UploadStatusResponse) +async def get_upload_status( + session_id: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[UploadSessionsService, Depends(UploadSessionsService)], +) -> UploadStatusResponse: + return await service.get_upload_status(actor_user_id=user.id, session_id=session_id) + + +@upload_sessions_router.post("/{session_id}/complete", response_model=CompleteUploadResponse) +async def complete_upload( + session_id: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[UploadSessionsService, Depends(UploadSessionsService)], +) -> CompleteUploadResponse: + return await service.complete_upload(actor_user_id=user.id, session_id=session_id) + + +@upload_sessions_router.delete("/{session_id}", response_model=AbortUploadResponse) +async def abort_upload( + session_id: UUID, + user: Annotated[User, Depends(get_current_user)], + service: Annotated[UploadSessionsService, Depends(UploadSessionsService)], +) -> AbortUploadResponse: + await service.abort_upload(actor_user_id=user.id, session_id=session_id) + return AbortUploadResponse() \ No newline at end of file diff --git a/src/handlers/api/v1/users/__init__.py b/src/handlers/api/v1/users/__init__.py new file mode 100644 index 0000000..4c59f1d --- /dev/null +++ b/src/handlers/api/v1/users/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from src.handlers.api.v1.users.profile.router import profile_router + +users_router = APIRouter(prefix='/users', tags=['users']) + +users_router.include_router(profile_router) \ No newline at end of file diff --git a/src/handlers/api/v1/users/profile/__init__.py b/src/handlers/api/v1/users/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/api/v1/users/profile/models.py b/src/handlers/api/v1/users/profile/models.py new file mode 100644 index 0000000..2301ad7 --- /dev/null +++ b/src/handlers/api/v1/users/profile/models.py @@ -0,0 +1,21 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +class ProfileResponse(BaseModel): + model_config = ConfigDict( + populate_by_name=True, + from_attributes=True, + ) + + id: UUID + email: EmailStr + + storage_quota_bytes: int = Field(..., alias='storageQuotaBytes') + storage_used_bytes: int = Field(..., alias='storageUsedBytes') + storage_reserved_bytes: int = Field(..., alias='storageReservedBytes') + + created_at: datetime = Field(..., alias='createdAt') + last_login_at: datetime = Field(..., alias='lastLoginAt') diff --git a/src/handlers/api/v1/users/profile/router.py b/src/handlers/api/v1/users/profile/router.py new file mode 100644 index 0000000..08bb317 --- /dev/null +++ b/src/handlers/api/v1/users/profile/router.py @@ -0,0 +1,15 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.database.repository.postgres.user.dtos import User +from src.handlers.api.v1.users.profile.models import ProfileResponse +from src.handlers.dependencies.auth import get_current_user + +profile_router = APIRouter(prefix='/profile') + +@profile_router.get('/', response_model=ProfileResponse) +async def get_profile( + user: Annotated[User, Depends(get_current_user)], +) -> ProfileResponse: + return ProfileResponse.model_validate(user) \ No newline at end of file diff --git a/src/handlers/dependencies/__init__.py b/src/handlers/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/dependencies/auth.py b/src/handlers/dependencies/auth.py new file mode 100644 index 0000000..af87ffd --- /dev/null +++ b/src/handlers/dependencies/auth.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from src.database.repository.postgres.user.dtos import User +from src.exceptions import InvalidToken +from src.services.auth.service import AuthService + +security = HTTPBearer() + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], + service: Annotated[AuthService, Depends(AuthService)], +) -> User: + user, _, token_type = await service.verify_token(credentials.credentials) + if token_type != 'access': + raise InvalidToken + + return user diff --git a/src/handlers/public/__init__.py b/src/handlers/public/__init__.py new file mode 100644 index 0000000..ce0f5a9 --- /dev/null +++ b/src/handlers/public/__init__.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from src.handlers.public.download import download_router + +public_router = APIRouter(prefix='/public', tags=['Public']) + +public_router.include_router(download_router) \ No newline at end of file diff --git a/src/handlers/public/download/__init__.py b/src/handlers/public/download/__init__.py new file mode 100644 index 0000000..2a81ebf --- /dev/null +++ b/src/handlers/public/download/__init__.py @@ -0,0 +1,3 @@ +from src.handlers.public.download.router import download_router + +__all__ = ['download_router',] \ No newline at end of file diff --git a/src/handlers/public/download/router.py b/src/handlers/public/download/router.py new file mode 100644 index 0000000..3a370b7 --- /dev/null +++ b/src/handlers/public/download/router.py @@ -0,0 +1,19 @@ +from pathlib import Path +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +download_router = APIRouter(prefix='/download') + +TEMPLATE_PATH = Path(__file__).parent.parent.parent.parent / 'templates' / 'download_page.html' + + +def _load_template(link_id: str) -> str: + template = TEMPLATE_PATH.read_text() + return template.replace('{{link_id}}', link_id) + + +@download_router.get('/{link_id}', response_class=HTMLResponse) +async def public_download_page(link_id: UUID): + return _load_template(str(link_id)) diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/integrations/minio/__init__.py b/src/integrations/minio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/integrations/minio/client.py b/src/integrations/minio/client.py new file mode 100644 index 0000000..6d6ecac --- /dev/null +++ b/src/integrations/minio/client.py @@ -0,0 +1,179 @@ +import asyncio +from datetime import datetime, timedelta, UTC + +import aioboto3 +from minio import Minio +from minio.datatypes import Bucket +from botocore.config import Config as BotoConfig + +from src.core.config import config + + +class MinioClient: + def __init__(self): + self._client = Minio( + endpoint=config.MINIO_ENDPOINT, + access_key=config.MINIO_ACCESS_KEY, + secret_key=config.MINIO_SECRET_KEY.get_secret_value(), + secure=config.MINIO_SECURE, + ) + + self._external_endpoint = config.MINIO_EXTERNAL_ENDPOINT + if self._external_endpoint: + self._external_for_sign = Minio( + endpoint=self._external_endpoint, + access_key=config.MINIO_ACCESS_KEY, + secret_key=config.MINIO_SECRET_KEY.get_secret_value(), + secure=config.MINIO_SECURE, + ) + else: + self._external_for_sign = self._client + + self._boto3_session = aioboto3.Session( + aws_access_key_id=config.MINIO_ACCESS_KEY, + aws_secret_access_key=config.MINIO_SECRET_KEY.get_secret_value(), + ) + self._boto3_config = BotoConfig( + signature_version='s3v4', + s3={'addressing_style': 'path'}, + ) + + async def list_buckets(self) -> list[Bucket]: + buckets = await asyncio.to_thread(self._client.list_buckets) + return list(buckets) + + async def create_bucket(self, bucket_name: str) -> None: + await asyncio.to_thread(self._client.make_bucket, bucket_name) + + async def delete_bucket_objects(self, bucket_name: str) -> None: + objects = await asyncio.to_thread(self._client.list_objects, bucket_name, recursive=True) + for obj in objects: + await asyncio.to_thread(self._client.remove_object, bucket_name, obj.object_name) + + async def delete_bucket(self, bucket_name: str) -> None: + await asyncio.to_thread(self._client.remove_bucket, bucket_name) + + async def delete_object(self, bucket_name: str, object_name: str) -> None: + await asyncio.to_thread(self._client.remove_object, bucket_name, object_name) + + async def get_presigned_download_url( + self, bucket_name: str, object_name: str, expires_in: int = 3600 + ) -> tuple[str, datetime]: + url = await asyncio.to_thread( + self._external_for_sign.presigned_get_object, + bucket_name, + object_name, + expires=timedelta(seconds=expires_in), + ) + + expires_at = datetime.now(UTC) + timedelta(seconds=expires_in) + return url, expires_at + + async def delete_objects(self, bucket_name: str, object_names: list[str]) -> None: + for object_name in object_names: + await self.delete_object(bucket_name, object_name) + + async def presigned_put_object( + self, bucket_name: str, object_name: str, expires: timedelta = timedelta(hours=1) + ) -> str: + return await asyncio.to_thread( + self._external_for_sign.presigned_put_object, + bucket_name, + object_name, + expires=expires, + ) + + async def create_multipart_upload(self, bucket_name: str, object_name: str) -> str: + return await asyncio.to_thread( + self._client._create_multipart_upload, # noqa: SLF001 + bucket_name, + object_name, + {}, + ) + + async def presigned_put_part_url( + self, + bucket_name: str, + object_name: str, + upload_id: str, + part_number: int, + expires: timedelta = timedelta(hours=1), + ) -> str: + endpoint = self._external_endpoint or config.MINIO_ENDPOINT + scheme = 'https' if config.MINIO_SECURE else 'http' + endpoint_url = f'{scheme}://{endpoint}' + + expires_seconds = int(expires.total_seconds()) + + async with self._boto3_session.client( + 's3', + endpoint_url=endpoint_url, + config=self._boto3_config, + ) as client: + return await client.generate_presigned_url( + ClientMethod='upload_part', + HttpMethod='PUT', + Params={ + 'Bucket': bucket_name, + 'Key': object_name, + 'UploadId': upload_id, + 'PartNumber': part_number, + }, + ExpiresIn=expires_seconds, + ) + + async def complete_multipart_upload( + self, + bucket_name: str, + object_name: str, + upload_id: str, + parts: list[dict], + ) -> None: + endpoint = self._external_endpoint or config.MINIO_ENDPOINT + scheme = 'https' if config.MINIO_SECURE else 'http' + endpoint_url = f'{scheme}://{endpoint}' + + s3_parts = [{'ETag': p['ETag'].strip('"'), 'PartNumber': p['PartNumber']} for p in parts] + + async with self._boto3_session.client( + 's3', + endpoint_url=endpoint_url, + config=self._boto3_config, + ) as client: + await client.complete_multipart_upload( + Bucket=bucket_name, + Key=object_name, + UploadId=upload_id, + MultipartUpload={'Parts': s3_parts}, + ) + + async def abort_multipart_upload( + self, + bucket_name: str, + object_name: str, + upload_id: str, + ) -> None: + endpoint = self._external_endpoint or config.MINIO_ENDPOINT + scheme = 'https' if config.MINIO_SECURE else 'http' + endpoint_url = f'{scheme}://{endpoint}' + + async with self._boto3_session.client( + 's3', + endpoint_url=endpoint_url, + config=self._boto3_config, + ) as client: + await client.abort_multipart_upload( + Bucket=bucket_name, + Key=object_name, + UploadId=upload_id, + ) + + async def stat_object(self, bucket_name: str, object_name: str) -> dict: + obj = await asyncio.to_thread(self._client.stat_object, bucket_name, object_name) + + return { + 'size': obj.size, + 'etag': obj.etag, + 'last_modified': obj.last_modified, + 'content_type': obj.content_type, + } diff --git a/src/integrations/redis/__init__.py b/src/integrations/redis/__init__.py new file mode 100644 index 0000000..2736656 --- /dev/null +++ b/src/integrations/redis/__init__.py @@ -0,0 +1,10 @@ +from src.integrations.redis.client import RedisClient +from src.integrations.redis.connection import init_redis_pool, close_redis_pool, get_redis + + +__all__ = [ + 'RedisClient', + 'close_redis_pool', + 'get_redis', + 'init_redis_pool', +] \ No newline at end of file diff --git a/src/integrations/redis/client.py b/src/integrations/redis/client.py new file mode 100644 index 0000000..aaf3f51 --- /dev/null +++ b/src/integrations/redis/client.py @@ -0,0 +1,69 @@ +from typing import Any, Annotated + +from fastapi.params import Depends +from redis.asyncio import Redis +import json + +from src.core.config import config +from src.exceptions import RedisException +from src.integrations.redis.connection import get_redis + + + +class RedisClient: + def __init__( + self, + redis_client: Annotated[Redis, Depends(get_redis)], + ): + self._redis = redis_client + + async def get(self, key: str) -> dict[str, Any] | None: + value = await self._redis.get(key) + if value is None: + return None + + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError) as exc: + raise RedisException from exc + + async def set(self, key: str, value: dict[str, Any], expire: int | None = config.CACHE_TTL) -> None: + if value is None: + return + + data = json.dumps(value, default=str) + + if expire: + await self._redis.setex(key, expire, data) + else: + await self._redis.set(key, data) + + async def delete(self, key: str) -> None: + await self._redis.delete(key) + + async def exists(self, key: str) -> bool: + return await self._redis.exists(key) > 0 + + async def update(self, key: str, value: Any, expire: int = config.CACHE_TTL) -> None: + await self.set(key, value, expire=expire) + + async def hset(self, key: str, mapping: dict) -> None: + await self._redis.hset(key, mapping=mapping) + + async def hgetall(self, key: str) -> dict: + return await self._redis.hgetall(key) + + async def hget(self, key: str, field: str) -> Any: + return await self._redis.hget(key, field) + + async def sadd(self, key: str, *values) -> int: + return await self._redis.sadd(key, *values) + + async def scard(self, key: str) -> int: + return await self._redis.scard(key) + + async def sismember(self, key: str, value) -> bool: + return bool(await self._redis.sismember(key, value)) + + async def expire(self, key: str, seconds: int) -> None: + await self._redis.expire(key, seconds) diff --git a/src/integrations/redis/connection.py b/src/integrations/redis/connection.py new file mode 100644 index 0000000..dad812f --- /dev/null +++ b/src/integrations/redis/connection.py @@ -0,0 +1,18 @@ +from redis.asyncio import Redis, ConnectionPool +from src.core.config import config + + +async def init_redis_pool() -> Redis: + """Initialize Redis connection pool""" + pool = ConnectionPool.from_url(str(config.REDIS_URL), encoding='utf-8', decode_responses=True) + return Redis(connection_pool=pool) + + +async def close_redis_pool(redis: Redis) -> None: + """Close Redis connection pool""" + await redis.close() + + +async def get_redis() -> Redis: + """Dependency for getting a Redis connection""" + return await init_redis_pool() \ No newline at end of file diff --git a/src/main.py b/src/main.py deleted file mode 100644 index df81591..0000000 --- a/src/main.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio -import sys -import traceback - -import click - -from src.server import server -from src.client.cli import cli - - -async def run_server(): - """Запускает сервер""" - await server() - - -if __name__ == '__main__': - if len(sys.argv) > 1 and sys.argv[1] == 'server': - try: - asyncio.run(run_server()) - except KeyboardInterrupt: - click.echo('\nСервер отключен') - except Exception: - traceback.print_exc() - else: - cli() diff --git a/src/middlewares/__init__.py b/src/middlewares/__init__.py new file mode 100644 index 0000000..a0b9c7c --- /dev/null +++ b/src/middlewares/__init__.py @@ -0,0 +1,11 @@ +from starlette.middleware.base import BaseHTTPMiddleware + +from src.middlewares.logging_middleware import RequestLoggingMiddleware + +MIDDLEWARES: list[type[BaseHTTPMiddleware]] = [ + RequestLoggingMiddleware, +] + +__all__ = [ + 'MIDDLEWARES', +] \ No newline at end of file diff --git a/src/middlewares/logging_middleware.py b/src/middlewares/logging_middleware.py new file mode 100644 index 0000000..1a75722 --- /dev/null +++ b/src/middlewares/logging_middleware.py @@ -0,0 +1,119 @@ +from collections.abc import Callable +import time +from typing import Any, Literal +import uuid + +from fastapi import HTTPException, Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from src.core.config import config +from src.core.logger import get_logger +from src.exceptions import BaseServerError + +logger = get_logger(__name__) + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """ + Middleware for logging HTTP requests and responses. + Logs information about the request, execution time, and response status. + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + context = await self.get_context(request) + await logger.ainfo(f'Request started on {request.method} {request.url.path}', context=context) + + start_time = time.perf_counter() + + try: + response = await call_next(request) + + except HTTPException as e: + await self.create_final_log('failed', request, context, start_time, e.status_code, e) + return JSONResponse( + status_code=e.status_code, + content={"detail": e.detail, "code": getattr(e, "code", None)} + ) + + except BaseServerError as e: + await self.create_final_log('failed', request, context, start_time, e.http_code, e) + return JSONResponse( + status_code=e.http_code, + content={"detail": e.message, "code": e.code} + ) + + except Exception as e: + await self.create_final_log('failed', request, context, start_time, 500, e) + if config.DEBUG: + raise + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"} + ) + + else: + response_size = self.get_response_size(response) + context['response_size'] = response_size + + await self.create_final_log('successful', request, context, start_time, response.status_code) + + response.headers['X-TRACE-ID'] = context['trace_id'] + response.headers['X-PROCESS-TIME'] = context['process_time'] + + return response + + @staticmethod + def get_response_size(response: Response) -> int: + try: + response_size = response.headers.get('Content-Length') + if response_size: + return int(response_size) + + if hasattr(response, 'body') and response.body is not None: + return len(response.body) + + return 0 + + except Exception as e: + logger.warning( + 'Error getting response size: %s', + e, + ) + return 0 + + @staticmethod + async def create_final_log( # noqa: PLR0913 + msg: Literal['successful', 'failed'], + request: Request, + context: dict, + start_time: float, + status: int | str, + e: Exception | None = None, + ) -> None: + process_time = time.perf_counter() - start_time + process_time = f'{process_time:.4f}' + context['process_time'] = process_time + context['response_status'] = status + + if msg == 'successful': + await logger.ainfo(f'Request completed {request.method} {request.url.path}', context=context) + else: + await logger.aerror(f'Request failed {request.method} {request.url.path}', context=context, exc_info=e) + + @staticmethod + async def get_context(request: Request) -> dict[str, Any]: + trace_id = str(uuid.uuid4()) + content_type = request.headers.get('Content-Type', '') + body = await request.body() + body = 'Not available for multipart/form-data' if 'multipart/form-data' in content_type else body.decode() + + return { + 'trace_id': trace_id, + 'client': { + 'ip_address': request.client.host, + 'port': request.client.port, + 'user-agent': request.headers.get('User-Agent'), + }, + 'request': {'body': body, 'query': str(request.query_params)}, + } \ No newline at end of file diff --git a/src/server/__init__.py b/src/server/__init__.py deleted file mode 100644 index 3ecc4f7..0000000 --- a/src/server/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from src.server.server import server - -__all__ = ['server'] diff --git a/src/server/auth.py b/src/server/auth.py deleted file mode 100644 index 0666773..0000000 --- a/src/server/auth.py +++ /dev/null @@ -1,69 +0,0 @@ -import json -import uuid -from pathlib import Path -from typing import Any - -from src.utils.constants import USERS_FILE -from src.utils.security import hash_password, verify_password - - -class UserAuth: - """Класс для управления авторизацией пользователей""" - - def __init__(self, users_file: Path = USERS_FILE): - self.users_file = users_file - self._ensure_users_file() - - def _ensure_users_file(self): - """Создает файл users.json, если его нет""" - if not self.users_file.exists(): - self._save_users({}) - - def _load_users(self) -> dict[str, Any]: - """Загружает пользователей из JSON файла""" - try: - if not self.users_file.exists(): - return {} - with self.users_file.open(encoding='utf-8') as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return {} - - def _save_users(self, users: dict[str, Any]): - """Сохраняет пользователей в JSON файл""" - with self.users_file.open('w', encoding='utf-8') as f: - json.dump(users, f, indent=2, ensure_ascii=False) - - def register_user(self, login: str, password: str) -> str | None: - """Регистрирует нового пользователя. Возвращает UUID или None, если логин занят""" - users = self._load_users() - - for user_data in users.values(): - if user_data.get('login') == login: - return None - - user_uuid = str(uuid.uuid4()) - users[user_uuid] = {'login': login, 'password_hash': hash_password(password)} - - self._save_users(users) - return user_uuid - - def authenticate(self, login: str, password: str) -> str | None: - """Аутентифицирует пользователя. Возвращает UUID или None""" - users = self._load_users() - - for user_uuid, user_data in users.items(): - if user_data.get('login') == login and verify_password(password, user_data.get('password_hash', '')): - return user_uuid - - return None - - def get_user_uuid(self, login: str) -> str | None: - """Получает UUID пользователя по логину""" - users = self._load_users() - - for user_uuid, user_data in users.items(): - if user_data.get('login') == login: - return user_uuid - - return None diff --git a/src/server/protocol.py b/src/server/protocol.py deleted file mode 100644 index 71dd7d6..0000000 --- a/src/server/protocol.py +++ /dev/null @@ -1,92 +0,0 @@ -import asyncio -import json -import struct -from typing import Any - -from src.utils.config import config -from src.utils.logger import server_logger - - -class ServerProtocol: - """Протокол для обработки команд от клиента""" - - @staticmethod - async def read_json_message(reader) -> dict[str, Any] | None: - """Читает JSON сообщение из потока""" - try: - length_bytes = await reader.readexactly(4) - length = struct.unpack('>I', length_bytes)[0] - - json_data = await reader.readexactly(length) - return json.loads(json_data.decode('utf-8')) - except asyncio.IncompleteReadError: - return None - except Exception as e: - server_logger.error(f'Error reading JSON message: {e}') - return None - - @staticmethod - async def read_binary_data(reader, size: int) -> bytes | None: - """Читает бинарные данные заданного размера""" - try: - length_bytes = await reader.readexactly(4) - data_length = struct.unpack('>I', length_bytes)[0] - - if data_length != size: - server_logger.warning(f'Несоответствие размера: ожидалось {size}, получено {data_length}') - - data = bytearray() - remaining = data_length - - while remaining > 0: - read_size = min(config.chunk_size, remaining) - chunk = await reader.readexactly(read_size) - data.extend(chunk) - remaining -= len(chunk) - - return bytes(data) - except Exception as e: - server_logger.error(f'Error reading binary data: {e}') - return None - - @staticmethod - async def send_json_message(writer, data: dict[str, Any]): - """Отправляет JSON сообщение клиенту""" - try: - json_data = json.dumps(data, ensure_ascii=False).encode('utf-8') - length = len(json_data) - - writer.write(struct.pack('>I', length)) - writer.write(json_data) - await writer.drain() - except Exception as e: - server_logger.error(f'Error sending JSON message: {e}') - - @staticmethod - async def send_binary_data(writer, data: bytes): - """Отправляет бинарные данные клиенту""" - try: - writer.write(struct.pack('>I', len(data))) - await writer.drain() - - offset = 0 - while offset < len(data): - chunk = data[offset : offset + config.chunk_size] - writer.write(chunk) - await writer.drain() - offset += len(chunk) - except Exception as e: - server_logger.error(f'Error sending binary data: {e}') - - @staticmethod - async def send_error(writer, message: str): - """Отправляет сообщение об ошибке""" - await ServerProtocol.send_json_message(writer, {'status': 'ERROR', 'message': message}) - - @staticmethod - async def send_ok(writer, data: Any = None): - """Отправляет успешный ответ""" - response = {'status': 'OK'} - if data is not None: - response['data'] = data - await ServerProtocol.send_json_message(writer, response) diff --git a/src/server/server.py b/src/server/server.py deleted file mode 100644 index 233347f..0000000 --- a/src/server/server.py +++ /dev/null @@ -1,245 +0,0 @@ -import asyncio -from typing import Any -from collections.abc import Callable, Coroutine - -from src.server.auth import UserAuth -from src.server.storage import FileStorage -from src.server.protocol import ServerProtocol -from src.utils.config import config -from src.utils.exceptions import ValidationError -from src.utils.logger import server_logger - - -class Server: - """Асинхронный TCP сервер для файлового хранилища""" - - def __init__(self): - self.auth = UserAuth() - self.storage = FileStorage() - self.authenticated_users: dict[asyncio.StreamWriter, str] = {} - - self.handlers: dict[ - str, Callable[[dict, asyncio.StreamReader, asyncio.StreamWriter, tuple], Coroutine[Any, Any, None]] - ] = { - 'REGISTER': self.handle_register, - 'AUTH': self.handle_auth, - 'LOGOUT': self.handle_logout, - 'LIST': self.handle_list, - 'GET': self.handle_get, - 'PUT': self.handle_put, - 'DELETE': self.handle_delete, - 'MOVE': self.handle_move, - } - - def _get_user_uuid(self, writer: asyncio.StreamWriter) -> str | None: - """Получает UUID пользователя для соединения""" - return self.authenticated_users.get(writer) - - def _require_auth(self, writer: asyncio.StreamWriter) -> str: - """Проверяет авторизацию и возвращает UUID пользователя""" - user_uuid = self._get_user_uuid(writer) - if not user_uuid: - raise ValidationError('Требуется авторизация') - return user_uuid - - def _validate_input(self, value: str, field_name: str, max_length: int | None = None) -> str: - """Валидирует входные данные""" - if not value or not isinstance(value, str): - raise ValidationError(f'{field_name} обязателен') - value = value.strip() - if max_length and len(value) > max_length: - raise ValidationError(f'{field_name} слишком длинный (максимум {max_length} символов)') - return value - - async def _execute_handler( - self, handler: Callable, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Выполняет хендлер с обработкой исключений""" - try: - await handler(command, reader, writer, addr) - except ValidationError as e: - await ServerProtocol.send_error(writer, str(e)) - server_logger.warning(f'Ошибка валидации: {e}') - except Exception as e: - server_logger.error(f'Ошибка в хендлере: {e}', exc_info=True) - await ServerProtocol.send_error(writer, 'Внутренняя ошибка сервера') - - async def handle_register( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду REGISTER""" - login = self._validate_input(command.get('login', ''), 'Логин', config.max_login_length) - password = command.get('password', '') - - if not password or len(password) < config.min_password_length: - raise ValidationError(f'Пароль должен быть не менее {config.min_password_length} символов') - - user_uuid = self.auth.register_user(login, password) - - if user_uuid: - await ServerProtocol.send_ok(writer, {'message': 'Пользователь зарегистрирован', 'uuid': user_uuid}) - server_logger.info(f'Зарегистрирован новый пользователь: {login} (UUID: {user_uuid})') - else: - await ServerProtocol.send_error(writer, 'Логин уже занят') - server_logger.warning(f'Попытка регистрации с занятым логином: {login}') - - async def handle_auth( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду AUTH""" - login = self._validate_input(command.get('login', ''), 'Логин') - password = command.get('password', '') - - if not password: - raise ValidationError('Пароль обязателен') - - user_uuid = self.auth.authenticate(login, password) - - if user_uuid: - self.authenticated_users[writer] = user_uuid - await ServerProtocol.send_ok(writer, {'message': 'Авторизация успешна'}) - server_logger.info(f'Клиент {addr[0]}:{addr[1]} авторизован как {login}') - else: - await ServerProtocol.send_error(writer, 'Неверный логин или пароль') - server_logger.warning(f'Неудачная попытка авторизации: {login} с {addr[0]}:{addr[1]}') - - async def handle_logout( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду LOGOUT""" - if writer in self.authenticated_users: - del self.authenticated_users[writer] - await ServerProtocol.send_ok(writer, {'message': 'Выход выполнен успешно'}) - server_logger.info(f'Клиент {addr[0]}:{addr[1]} вышел из аккаунта') - else: - await ServerProtocol.send_ok(writer, {'message': 'Выход выполнен успешно'}) - server_logger.debug(f'Попытка выхода неавторизованного клиента {addr[0]}:{addr[1]}') - - async def handle_list( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду LIST""" - user_uuid = self._require_auth(writer) - path = command.get('path', '') - - files = self.storage.list_files(user_uuid, path) - await ServerProtocol.send_ok(writer, {'files': files}) - server_logger.debug(f'Список файлов для пользователя {user_uuid}: {len(files)} элементов') - - async def handle_get( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду GET""" - user_uuid = self._require_auth(writer) - path = self._validate_input(command.get('path', ''), 'Путь', config.max_path_length) - - file_data = self.storage.get_file(user_uuid, path) - if file_data is None: - await ServerProtocol.send_error(writer, 'Файл не найден') - server_logger.warning(f'Файл не найден: {path} для пользователя {user_uuid}') - else: - await ServerProtocol.send_ok(writer, {'filename': path.split('/')[-1], 'size': len(file_data)}) - await ServerProtocol.send_binary_data(writer, file_data) - server_logger.info(f'Файл отправлен: {path} ({len(file_data)} байт) для пользователя {user_uuid}') - - async def handle_put( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду PUT""" - user_uuid = self._require_auth(writer) - path = self._validate_input(command.get('path', ''), 'Путь', config.max_path_length) - file_size = command.get('size', 0) - - if file_size > config.max_file_size: - raise ValidationError(f'Файл слишком большой (максимум {config.max_file_size} байт)') - - if file_size <= 0: - raise ValidationError('Неверный размер файла') - - file_data = await ServerProtocol.read_binary_data(reader, file_size) - if file_data is None or len(file_data) != file_size: - raise ValidationError('Ошибка чтения файла') - - if self.storage.put_file(user_uuid, path, file_data): - await ServerProtocol.send_ok(writer, {'message': 'Файл сохранен'}) - server_logger.info(f'Файл сохранен: {path} ({file_size} байт) для пользователя {user_uuid}') - else: - await ServerProtocol.send_error(writer, 'Ошибка сохранения файла') - server_logger.error(f'Ошибка сохранения файла: {path} для пользователя {user_uuid}') - - async def handle_delete( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду DELETE""" - user_uuid = self._require_auth(writer) - path = self._validate_input(command.get('path', ''), 'Путь', config.max_path_length) - - if self.storage.delete_file(user_uuid, path): - await ServerProtocol.send_ok(writer, {'message': 'Удалено успешно'}) - server_logger.info(f'Удалено: {path} для пользователя {user_uuid}') - else: - await ServerProtocol.send_error(writer, 'Ошибка удаления') - server_logger.warning(f'Ошибка удаления: {path} для пользователя {user_uuid}') - - async def handle_move( - self, command: dict, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, addr: tuple - ) -> None: - """Обрабатывает команду MOVE""" - user_uuid = self._require_auth(writer) - source = self._validate_input(command.get('source', ''), 'Исходный путь', config.max_path_length) - destination = self._validate_input(command.get('destination', ''), 'Путь назначения', config.max_path_length) - - if self.storage.move_file(user_uuid, source, destination): - await ServerProtocol.send_ok(writer, {'message': 'Перемещено успешно'}) - server_logger.info(f'Перемещено: {source} -> {destination} для пользователя {user_uuid}') - else: - await ServerProtocol.send_error(writer, 'Ошибка перемещения: файл не найден') - server_logger.warning(f'Ошибка перемещения: {source} для пользователя {user_uuid}') - - async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - """Обрабатывает подключение клиента""" - addr = writer.get_extra_info('peername') - server_logger.info(f'Подключился клиент {addr[0]}:{addr[1]}') - - try: - while True: - command = await ServerProtocol.read_json_message(reader) - if not command: - break - - cmd_type: str = command.get('command', '').upper() - - if cmd_type not in self.handlers: - await ServerProtocol.send_error(writer, f'Неизвестная команда: {cmd_type}') - server_logger.warning(f'Неизвестная команда от {addr[0]}:{addr[1]}: {cmd_type}') - continue - - handler = self.handlers[cmd_type] - await self._execute_handler(handler, command, reader, writer, addr) - - except asyncio.IncompleteReadError: - server_logger.info(f'Клиент {addr[0]}:{addr[1]} отключился') - except Exception as e: - server_logger.error(f'Ошибка при обработке клиента {addr[0]}:{addr[1]}: {e}', exc_info=True) - finally: - if writer in self.authenticated_users: - del self.authenticated_users[writer] - writer.close() - await writer.wait_closed() - server_logger.info(f'Соединение с клиентом {addr[0]}:{addr[1]} закрыто') - - async def start(self): - """Запускает сервер""" - server = await asyncio.start_server(self.handle_client, config.host, config.port) - - addr = server.sockets[0].getsockname() - server_logger.info(f'Сервер запущен на {addr[0]}:{addr[1]}') - - async with server: - await server.serve_forever() - - -async def server(): - """Точка входа для запуска сервера""" - s = Server() - await s.start() diff --git a/src/server/storage.py b/src/server/storage.py deleted file mode 100644 index bd0f564..0000000 --- a/src/server/storage.py +++ /dev/null @@ -1,151 +0,0 @@ -import shutil -from pathlib import Path -from typing import Any - -from src.utils.constants import STORAGE_DIR -from src.utils.config import config -from src.utils.exceptions import ValidationError -from src.utils.logger import server_logger - - -class FileStorage: - """Класс для управления файловым хранилищем пользователей""" - - def __init__(self, storage_dir: Path = STORAGE_DIR): - self.storage_dir = storage_dir - self.storage_dir.mkdir(parents=True, exist_ok=True) - - def get_user_dir(self, user_uuid: str) -> Path: - """Возвращает путь к директории пользователя""" - user_dir = self.storage_dir / user_uuid - user_dir.mkdir(parents=True, exist_ok=True) - return user_dir - - def _resolve_path(self, user_uuid: str, path: str) -> Path: - """Разрешает путь относительно директории пользователя""" - if not path: - return self.get_user_dir(user_uuid) - - if len(path) > config.max_path_length: - raise ValidationError(f'Путь слишком длинный (максимум {config.max_path_length} символов)') - - user_dir = self.get_user_dir(user_uuid) - - normalized_path = path.lstrip('/') - normalized_path = normalized_path.replace('../', '').replace('..\\', '') - if '..' in normalized_path: - raise ValidationError('Path traversal detected') - - if normalized_path.startswith('/') or '\\' in normalized_path: - raise ValidationError('Недопустимые символы в пути') - - resolved = (user_dir / normalized_path).resolve() - - try: - resolved.relative_to(user_dir.resolve()) - except ValueError as e: - raise ValidationError('Path traversal detected') from e - - return resolved - - def list_files(self, user_uuid: str, path: str = '') -> list[dict[str, Any]]: - """Возвращает список файлов и папок в указанном пути""" - try: - target_path = self._resolve_path(user_uuid, path) - - if not target_path.exists(): - return [] - - if target_path.is_file(): - return [{'name': target_path.name, 'type': 'file', 'size': target_path.stat().st_size}] - - items = [] - for item in target_path.iterdir(): - items.append( - { - 'name': item.name, - 'type': 'directory' if item.is_dir() else 'file', - 'size': item.stat().st_size if item.is_file() else 0, - } - ) - - return sorted(items, key=lambda x: (x['type'] == 'file', x['name'])) - except (ValidationError, ValueError) as e: - raise ValidationError(f'Error listing files: {e!s}') from e - except Exception as e: - server_logger.error(f'Неожиданная ошибка при получении списка файлов: {e}') - raise ValueError(f'Error listing files: {e!s}') from e - - def get_file(self, user_uuid: str, path: str) -> bytes | None: - """Читает файл и возвращает его содержимое""" - try: - file_path = self._resolve_path(user_uuid, path) - - if not file_path.exists() or not file_path.is_file(): - return None - - with file_path.open('rb') as f: - return f.read() - except Exception: - return None - - def put_file(self, user_uuid: str, path: str, data: bytes) -> bool: - """Записывает файл в хранилище""" - try: - if len(data) > config.max_file_size: - raise ValidationError(f'Файл слишком большой (максимум {config.max_file_size} байт)') - - file_path = self._resolve_path(user_uuid, path) - - file_path.parent.mkdir(parents=True, exist_ok=True) - - with file_path.open('wb') as f: - f.write(data) - - return True - except (ValidationError, ValueError) as e: - server_logger.warning(f'Ошибка валидации при сохранении файла: {e}') - return False - except Exception as e: - server_logger.error(f'Ошибка при сохранении файла: {e}') - return False - - def delete_file(self, user_uuid: str, path: str) -> bool: - """Удаляет файл или директорию""" - try: - target_path = self._resolve_path(user_uuid, path) - - if not target_path.exists(): - return False - - if target_path.is_file(): - target_path.unlink() - elif target_path.is_dir(): - shutil.rmtree(target_path) - - return True - except Exception: - return False - - def move_file(self, user_uuid: str, source_path: str, destination_path: str) -> bool: - """Перемещает или переименовывает файл или директорию""" - try: - source = self._resolve_path(user_uuid, source_path) - destination = self._resolve_path(user_uuid, destination_path) - - if not source.exists(): - return False - - if destination.exists(): - raise ValidationError('Файл назначения уже существует') - - destination.parent.mkdir(parents=True, exist_ok=True) - - shutil.move(str(source), str(destination)) - - return True - except ValidationError: - raise - except Exception as e: - server_logger.error(f'Ошибка при перемещении файла: {e}') - return False diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/access/__init__.py b/src/services/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/access/service.py b/src/services/access/service.py new file mode 100644 index 0000000..500a7da --- /dev/null +++ b/src/services/access/service.py @@ -0,0 +1,77 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.database.repository import ( + BucketRepository, + FolderRepository, + FileRepository, + BucketPermissionRepository, +) +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.exceptions import NotFound, PermissionDenied + + +class AccessService: + def __init__( + self, + bucket_repository: Annotated[BucketRepository, Depends(BucketRepository)], + folder_repository: Annotated[FolderRepository, Depends(FolderRepository)], + file_repository: Annotated[FileRepository, Depends(FileRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + ): + self._bucket_repository = bucket_repository + self._folder_repository = folder_repository + self._file_repository = file_repository + self._bucket_permission_repository = bucket_permission_repository + + async def check_file_access( + self, + user_id: UUID, + file_id: UUID, + required_permission: PermissionType = PermissionType.READ, + ) -> tuple[UUID, UUID]: + file = await self._file_repository.get_by_id(file_id) + if not file: + raise NotFound('File not found') + + await self.check_bucket_access(user_id, file.bucket_id, required_permission) + return file.bucket_id, file.owner_id + + async def check_folder_access( + self, + user_id: UUID, + folder_id: UUID, + required_permission: PermissionType = PermissionType.READ, + ) -> tuple[UUID, UUID]: + folder = await self._folder_repository.get_by_id(folder_id) + if not folder: + raise NotFound('Folder not found') + + await self.check_bucket_access(user_id, folder.bucket_id, required_permission) + return folder.bucket_id, folder.owner_id + + async def check_bucket_access( + self, + user_id: UUID, + bucket_id: UUID, + required_permission: PermissionType = PermissionType.READ, + ) -> None: + bucket = await self._bucket_repository.get_by_id(bucket_id) + if not bucket: + raise NotFound('Bucket not found') + + if bucket.owner_id == user_id: + return + + if bucket.is_public and required_permission == PermissionType.READ: + return + + permission = await self._bucket_permission_repository.get_by_bucket_and_user(bucket_id, user_id) + if not permission: + raise PermissionDenied('Access denied to this bucket') + + rank = {PermissionType.READ: 1, PermissionType.WRITE: 2, PermissionType.ADMIN: 3} + if rank[permission.permission_type] < rank[required_permission]: + raise PermissionDenied('Insufficient permissions for this operation') diff --git a/src/services/auth/__init__.py b/src/services/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/auth/models.py b/src/services/auth/models.py new file mode 100644 index 0000000..770d475 --- /dev/null +++ b/src/services/auth/models.py @@ -0,0 +1,40 @@ +from enum import StrEnum + +from pydantic import BaseModel, Field, ConfigDict +from datetime import datetime + + + +class JWTToken(BaseModel): + token: str + expires_at: datetime + +class JWTTokens(BaseModel): + access: JWTToken + refresh: JWTToken + +class TokenResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + access_token: str = Field(..., alias='accessToken') + refresh_token: str = Field(..., alias='refreshToken') + +class SessionTypes(StrEnum): + register= 'register' + login = 'login' + reset_password= 'reset_password' + +class AuthSession(BaseModel): + model_config = ConfigDict(use_enum_values=True) + + type: SessionTypes + email: str + password: str | None = None + code: str + expires_at: datetime + +class AuthRedisPrefixes: + base_prefix = 'auth' + registration_prefix = f'{base_prefix}:register' + login_prefix = f'{base_prefix}:login' + password_reset_prefix = f'{base_prefix}:password_reset' \ No newline at end of file diff --git a/src/services/auth/permissions.py b/src/services/auth/permissions.py new file mode 100644 index 0000000..e5fe841 --- /dev/null +++ b/src/services/auth/permissions.py @@ -0,0 +1 @@ +# TODO: move permissions for buckets, folders and files into this file \ No newline at end of file diff --git a/src/services/auth/service.py b/src/services/auth/service.py new file mode 100644 index 0000000..bab0260 --- /dev/null +++ b/src/services/auth/service.py @@ -0,0 +1,292 @@ +import random +import string +from datetime import datetime, timedelta, UTC +from typing import Annotated, Literal, Any +from collections.abc import Callable, Coroutine +from uuid import UUID + +from argon2 import PasswordHasher +from fastapi import Depends +from pydantic import ValidationError + +from src.core.config import config +from src.database.repository import UserRepository +from src.database.repository.postgres.user.dtos import User +from src.exceptions import ClientError, NotFound, EmailAlreadyExists, SessionExpired, InvalidSessionData, \ + InvalidSessionType, InvalidCode, InvalidCredentials, InvalidToken +from src.integrations.redis import RedisClient +from src.services.auth.models import JWTTokens, JWTToken, SessionTypes, AuthRedisPrefixes, AuthSession +from src.services.security import JWTHandler +from src.services.ses import YandexSESService + + +class AuthService: + def __init__( + self, + users_repository: Annotated[UserRepository, Depends(UserRepository)], + email_service: Annotated[YandexSESService, Depends(YandexSESService)], + redis_cache: Annotated[RedisClient, Depends(RedisClient)], + jwt_handler: Annotated[JWTHandler, Depends(JWTHandler)], + ): + self._users_repository = users_repository + self._email_service = email_service + self._redis_cache = redis_cache + self._password_hasher = PasswordHasher() + self._jwt = jwt_handler + + @staticmethod + def _generate_code() -> str: + return ''.join(random.choices(string.digits, k=6)) + + @staticmethod + def _get_expires_at(minutes: int = 10) -> datetime: + return datetime.now(UTC) + timedelta(minutes=minutes) + + @staticmethod + def _get_register_redis_key(email: str) -> str: + return f'{AuthRedisPrefixes.registration_prefix}:{email}' + + @staticmethod + def _get_login_redis_key(email: str) -> str: + return f'{AuthRedisPrefixes.login_prefix}:{email}' + + @staticmethod + def _get_password_reset_redis_key(email: str) -> str: + return f'{AuthRedisPrefixes.password_reset_prefix}:{email}' + + # --------- REGISTER --------- + async def start_registration(self, email: str, password: str) -> None: + redis_key = self._get_register_redis_key(email) + existing = await self._users_repository.get_by_email(email=email) + if existing: + raise EmailAlreadyExists + + await self._redis_cache.delete(redis_key) + + hashed_password = self._password_hasher.hash(password) + code = self._generate_code() + + session = AuthSession( + type=SessionTypes.register, + email=email, + password=hashed_password, + code=code, + expires_at=self._get_expires_at() + ) + await self._redis_cache.set(redis_key, session.model_dump(), expire=config.AUTH_CACHE_TTL) + await self._email_service.send_verification_email(user_email=email, token=code) + + async def complete_registration(self, email: str, password: str, code: str) -> JWTTokens: + redis_key = self._get_register_redis_key(email) + data = await self._redis_cache.get(redis_key) + if not data: + raise SessionExpired + + try: + session = AuthSession.model_validate(data) + except ValidationError as exc: + raise InvalidSessionData from exc + + if session.type != SessionTypes.register: + raise InvalidSessionType + + if session.code != code: + raise InvalidCode + + if not self._password_hasher.verify(session.password, password): + raise InvalidCredentials + + if session.expires_at < datetime.now(UTC): + await self._redis_cache.delete(redis_key) + raise ClientError(message="Code expired", code="code_expired") + + existing = await self._users_repository.get_by_email(email=email) + if existing: + raise EmailAlreadyExists + + user = await self._users_repository.create( + entity=User( + email=email, + password_hash=session.password, + ), + ) + + await self._redis_cache.delete(redis_key) + return await self._create_tokens(user.id) + + # --------- LOGIN --------- + async def start_login(self, email: str, password: str) -> None: + redis_key = self._get_login_redis_key(email) + user = await self._users_repository.get_by_email(email=email) + if not user: + raise InvalidCredentials + + try: + self._password_hasher.verify(user.password_hash, password) + except Exception as exc: + raise InvalidCredentials from exc + + await self._redis_cache.delete(redis_key) + + code = self._generate_code() + session = AuthSession( + type=SessionTypes.login, + email=email, + code=code, + expires_at=self._get_expires_at(), + ) + await self._redis_cache.set(redis_key, session.model_dump(), expire=config.AUTH_CACHE_TTL) + await self._email_service.send_verification_email(user_email=email, token=code) + + async def complete_login(self, email: str, password: str, code: str) -> JWTTokens: + redis_key = self._get_login_redis_key(email) + data = await self._redis_cache.get(redis_key) + if not data: + raise SessionExpired + + try: + session = AuthSession.model_validate(data) + except ValidationError as exc: + raise InvalidSessionData from exc + + if session.type != SessionTypes.login: + raise InvalidSessionType + + if session.code != code: + raise InvalidCode + + if session.expires_at < datetime.now(UTC): + await self._redis_cache.delete(redis_key) + raise SessionExpired + + user = await self._users_repository.get_by_email(email=email) + if not user: + raise NotFound(message="User not found") + + try: + self._password_hasher.verify(user.password_hash, password) + except Exception as exc: + raise InvalidCredentials from exc + + await self._redis_cache.delete(redis_key) + return await self._create_tokens(user.id) + + # --------- RESET PASSWORD --------- + async def start_password_reset(self, email: str, new_password: str) -> None: + redis_key = self._get_password_reset_redis_key(email) + user = await self._users_repository.get_by_email(email=email) + if not user: + raise NotFound(message="User with this email not found") + + await self._redis_cache.delete(redis_key) + + new_hashed = self._password_hasher.hash(new_password) + code = self._generate_code() + + session = AuthSession( + type=SessionTypes.reset_password, + email=email, + password=new_hashed, + code=code, + expires_at=self._get_expires_at() + ) + await self._redis_cache.set(redis_key, session.model_dump(), expire=config.AUTH_CACHE_TTL) + await self._email_service.send_verification_email(user_email=email, token=code) + + async def complete_password_reset(self, email: str, password: str, code: str) -> JWTTokens: + redis_key = self._get_password_reset_redis_key(email) + data = await self._redis_cache.get(redis_key) + if not data: + raise SessionExpired + + try: + session = AuthSession.model_validate(data) + except ValidationError as exc: + raise InvalidSessionData from exc + + if session.type != SessionTypes.reset_password: + raise InvalidSessionType + + if session.code != code: + raise InvalidCode + + if not self._password_hasher.verify(session.password, password): + raise InvalidCredentials + + if session.expires_at < datetime.now(UTC): + await self._redis_cache.delete(redis_key) + raise SessionExpired + + user = await self._users_repository.get_by_email(email=email) + if not user: + raise NotFound(message="User not found") + + user.password_hash = session.password + await self._users_repository.update(user) + + await self._redis_cache.delete(redis_key) + return await self._create_tokens(user.id) + + # --------- TWO-FACTOR DISPATCHER --------- + async def complete_operation( + self, + operation: Literal['register', 'login', 'reset_password'], + email: str, + password: str, + code: str, + ) -> JWTTokens: + mapping: dict[str, Callable[[str, str, str], Coroutine[Any, Any, JWTTokens]]] = { + "register": self.complete_registration, + "login": self.complete_login, + "reset_password": self.complete_password_reset, + } + method = mapping.get(operation) + if not method: + raise ClientError(message="Invalid operation", code="invalid_operation") + + return await method(email, password, code) + + # --------- TOKENS --------- + async def _create_tokens(self, user_id: UUID) -> JWTTokens: + access_token, access_exp = self._jwt.get_access_token(user_id=user_id) + refresh_token, refresh_exp = self._jwt.get_refresh_token(user_id=user_id) + return JWTTokens( + access=JWTToken(token=access_token, expires_at=access_exp), + refresh=JWTToken(token=refresh_token, expires_at=refresh_exp) + ) + + async def refresh_tokens(self, refresh_token: str) -> JWTTokens: + try: + payload = self._jwt.decode_token(refresh_token) + if payload.get("type") != "refresh": + raise InvalidToken + + if self._jwt.is_token_expired(refresh_token): + raise InvalidToken + + user_id = payload.get("sub") + if not user_id: + raise InvalidToken + + return await self._create_tokens(UUID(user_id)) + except Exception as exc: + raise InvalidToken from exc + + async def verify_token(self, token: str) -> tuple[User, datetime, str]: + try: + payload = self._jwt.decode_token(token) + if not payload or "sub" not in payload: + raise InvalidToken + + if self._jwt.is_token_expired(token): + raise InvalidToken + + user_id = payload["sub"] + user = await self._users_repository.get_by_id(UUID(user_id)) + if not user: + raise NotFound(message="User not found") + + expiration = self._jwt.get_token_expiration(token) + return user, expiration, payload.get("type", "access") + except Exception as exc: + raise InvalidToken from exc diff --git a/src/services/buckets/__init__.py b/src/services/buckets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/buckets/models.py b/src/services/buckets/models.py new file mode 100644 index 0000000..9cf1e31 --- /dev/null +++ b/src/services/buckets/models.py @@ -0,0 +1,16 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from src.database.repository.postgres.bucket_permission.dtos import PermissionType + + +class UserBrief(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: UUID + email: str + created_at: datetime + last_login_at: datetime | None = None + permission: PermissionType | None = None diff --git a/src/services/buckets/service.py b/src/services/buckets/service.py new file mode 100644 index 0000000..db6a680 --- /dev/null +++ b/src/services/buckets/service.py @@ -0,0 +1,277 @@ +import uuid +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.database.repository import ( + UserRepository, + FolderRepository, + BucketRepository, + BucketPermissionRepository, + FileRepository, +) +from src.database.repository.postgres.bucket.dtos import Bucket +from src.database.repository.postgres.bucket_permission.dtos import PermissionType, BucketPermission +from src.database.repository.postgres.user.dtos import User +from src.exceptions import PermissionDenied, NotFound, Conflict, ClientError +from src.integrations.minio.client import MinioClient +from src.services.buckets.models import UserBrief +from src.services.ses import YandexSESService + + +class BucketsService: + def __init__( # noqa: PLR0913 + self, + users_repository: Annotated[UserRepository, Depends(UserRepository)], + buckets_repository: Annotated[BucketRepository, Depends(BucketRepository)], + buckets_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + folders_repository: Annotated[FolderRepository, Depends(FolderRepository)], + file_repository: Annotated[FileRepository, Depends(FileRepository)], + minio_client: Annotated[MinioClient, Depends(MinioClient)], + email_service: Annotated[YandexSESService, Depends(YandexSESService)], + ): + self._users_repository = users_repository + self._buckets_repository = buckets_repository + self._buckets_permission_repository = buckets_permission_repository + self._folders_repository = folders_repository + self._file_repository = file_repository + self._minio_client = minio_client + self._email_service = email_service + + async def _check_bucket_access(self, user_id: UUID, bucket_id: UUID, required_permission: PermissionType) -> Bucket: + bucket = await self._buckets_repository.get_by_id(bucket_id) + if not bucket: + raise NotFound(message='Bucket not found', code='bucket_not_found') + + if bucket.owner_id == user_id: + return bucket + + if bucket.is_public and required_permission == PermissionType.READ: + return bucket + + permission = await self._buckets_permission_repository.get_user_permission(user_id, bucket_id) + if not permission: + raise PermissionDenied(message='Access denied to this bucket') + + permission_rank = { + PermissionType.READ: 1, + PermissionType.WRITE: 2, + PermissionType.ADMIN: 3, + } + required_rank = permission_rank[required_permission] + actual_rank = permission_rank[permission.permission_type] + + if actual_rank < required_rank: + raise PermissionDenied(message='Insufficient permissions for this operation') + + return bucket + + async def get_buckets(self, user_id: UUID) -> list[Bucket]: + buckets = await self._buckets_repository.get_accessible_buckets(user_id) + + for bucket in buckets: + bucket_id = bucket.id + if bucket_id is None: + continue + + if bucket.owner_id == user_id: + bucket.permission = PermissionType.ADMIN + else: + permission = await self._buckets_permission_repository.get_user_permission(bucket_id, user_id) + if permission: + bucket.permission = permission.permission_type + elif bucket.is_public: + bucket.permission = PermissionType.READ + else: + bucket.permission = None + + files_count, size = await self._file_repository.get_bucket_stats(bucket_id) + bucket.files_count = files_count + bucket.size = size + + return buckets + + async def create_bucket(self, user_id: UUID, name: str, is_public: bool) -> Bucket: + minio_bucket_name = f'netvault-{uuid.uuid4()}' + + await self._minio_client.create_bucket(minio_bucket_name) + + bucket = Bucket( + name=name, + owner_id=user_id, + is_public=is_public, + minio_bucket_name=minio_bucket_name, + ) + return await self._buckets_repository.create(bucket) + + async def get_bucket(self, user_id: UUID, bucket_id: UUID) -> Bucket: + return await self._check_bucket_access(user_id, bucket_id, PermissionType.READ) + + async def update_bucket(self, user_id: UUID, bucket_id: UUID, name: str, is_public: bool) -> Bucket: + bucket = await self._check_bucket_access(user_id, bucket_id, PermissionType.ADMIN) + + bucket.name = name + bucket.is_public = is_public + return await self._buckets_repository.update(bucket) + + async def delete_bucket(self, user_id: UUID, bucket_id: UUID) -> None: + bucket = await self._check_bucket_access(user_id, bucket_id, PermissionType.ADMIN) + + files = await self._file_repository.get_by_bucket(bucket_id) + total_size = sum(f.file_size_bytes for f in files) + + await self._minio_client.delete_bucket_objects(bucket.minio_bucket_name) + + await self._minio_client.delete_bucket(bucket.minio_bucket_name) + + await self._buckets_repository.delete(bucket_id) + + if total_size > 0: + owner = await self._users_repository.get_by_id(bucket.owner_id) + if owner: + owner.storage_used_bytes -= total_size + await self._users_repository.update(owner) + + # PERMISSIONS + async def _get_target_user_id(self, email: str | None, target_user_id: UUID | None) -> User: + if email and target_user_id: + raise ClientError(message='Provide either email or user_id, not both', code='invalid_request') + + if email: + user = await self._users_repository.get_by_email(email) + if not user: + raise NotFound(message='User with this email not found', code='user_not_found') + return user + + if target_user_id: + user = await self._users_repository.get_by_id(target_user_id) + if not user: + raise NotFound(message='User not found', code='user_not_found') + return user + + raise ClientError(message='Either email or user_id must be provided', code='invalid_request') + + async def _check_target_not_owner(self, bucket: Bucket, target_user_id: UUID) -> None: + if bucket.owner_id == target_user_id: + raise ClientError(message='Cannot manage permissions for the bucket owner', code='target_is_owner') + + async def get_bucket_users(self, actor_user_id: UUID, bucket_id: UUID) -> list[UserBrief]: + bucket = await self._check_bucket_access(actor_user_id, bucket_id, PermissionType.READ) + + user_ids = {bucket.owner_id} + + permission_user_ids = await self._buckets_permission_repository.get_user_ids_by_bucket(bucket_id) + user_ids.update(permission_user_ids) + + permissions = await self._buckets_permission_repository.get_permissions_by_bucket(bucket_id) + permissions_map = {p.user_id: p.permission_type for p in permissions} + + users = await self._users_repository.get_by_ids(list(user_ids)) + + result = [] + for u in users: + perm = PermissionType.ADMIN if u.id == bucket.owner_id else permissions_map.get(u.id) + + result.append( + UserBrief( + id=u.id, + email=u.email, + created_at=u.created_at, + last_login_at=u.last_login_at, + permission=perm, + ) + ) + + return result + + async def grant_permission( + self, + actor_user_id: UUID, + bucket_id: UUID, + email: str | None, + target_user_id: UUID | None, + permission: PermissionType, + ) -> BucketPermission: + actor_user = await self._users_repository.get_by_id(actor_user_id) + bucket = await self._check_bucket_access(actor_user.id, bucket_id, PermissionType.ADMIN) + target_user = await self._get_target_user_id(email, target_user_id) + + await self._check_target_not_owner(bucket, target_user.id) + + existing = await self._buckets_permission_repository.get_by_bucket_and_user(bucket_id, target_user.id) + if existing: + raise Conflict(message='Permission already exists for this user', code='permission_exists') + + new_permission = BucketPermission( + bucket_id=bucket_id, + user_id=target_user.id, + permission_type=permission, + granted_by=actor_user.id, + ) + created = await self._buckets_permission_repository.create(new_permission) + + await self._email_service.send_bucket_permission_changed_email( + user_email=target_user.email, + bucket_name=bucket.name, + permission=permission.value, + granted_by=actor_user.email, + ) + + return created + + async def update_permission( + self, + actor_user_id: UUID, + bucket_id: UUID, + email: str | None, + target_user_id: UUID | None, + permission: PermissionType, + ) -> BucketPermission: + actor_user = await self._users_repository.get_by_id(actor_user_id) + bucket = await self._check_bucket_access(actor_user.id, bucket_id, PermissionType.ADMIN) + target_user = await self._get_target_user_id(email, target_user_id) + + await self._check_target_not_owner(bucket, target_user.id) + + existing = await self._buckets_permission_repository.get_by_bucket_and_user(bucket_id, target_user.id) + if not existing: + raise NotFound(message='Permission not found for this user', code='permission_not_found') + + existing.permission_type = permission + updated = await self._buckets_permission_repository.update(existing) + + await self._email_service.send_bucket_permission_changed_email( + user_email=target_user.email, + bucket_name=bucket.name, + permission=permission.value, + granted_by=actor_user.email, + ) + + return updated + + async def delete_permission( + self, + actor_user_id: UUID, + bucket_id: UUID, + email: str | None, + target_user_id: UUID | None, + ) -> None: + actor_user = await self._users_repository.get_by_id(actor_user_id) + bucket = await self._check_bucket_access(actor_user.id, bucket_id, PermissionType.ADMIN) + target_user = await self._get_target_user_id(email, target_user_id) + + await self._check_target_not_owner(bucket, target_user.id) + + existing = await self._buckets_permission_repository.get_by_bucket_and_user(bucket_id, target_user.id) + if not existing: + raise NotFound(message='Permission not found for this user', code='permission_not_found') + + await self._email_service.send_bucket_permission_changed_email( + user_email=target_user.email, + bucket_name=bucket.name, + permission="You haven't permissions now", + granted_by=actor_user.email, + ) + + await self._buckets_permission_repository.delete(existing.id) diff --git a/src/services/files/__init__.py b/src/services/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/files/models.py b/src/services/files/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/files/service.py b/src/services/files/service.py new file mode 100644 index 0000000..cd3edf4 --- /dev/null +++ b/src/services/files/service.py @@ -0,0 +1,158 @@ +from datetime import datetime +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.database.repository import ( + UserRepository, + BucketRepository, + BucketPermissionRepository, + FolderRepository, + FileRepository, +) +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.file.dtos import File +from src.exceptions import NotFound, ClientError, Conflict +from src.integrations.minio.client import MinioClient +from src.services.access.service import AccessService + + +class FilesService: + def __init__( # noqa: PLR0913 + self, + users_repository: Annotated[UserRepository, Depends(UserRepository)], + buckets_repository: Annotated[BucketRepository, Depends(BucketRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + folders_repository: Annotated[FolderRepository, Depends(FolderRepository)], + files_repository: Annotated[FileRepository, Depends(FileRepository)], + minio_client: Annotated[MinioClient, Depends(MinioClient)], + access_service: Annotated[AccessService, Depends(AccessService)], + ): + self._users_repository = users_repository + self._buckets_repository = buckets_repository + self._bucket_permission_repository = bucket_permission_repository + self._folders_repository = folders_repository + self._files_repository = files_repository + self._minio_client = minio_client + self._access_service = access_service + + async def _get_user_permission(self, user_id: UUID, bucket_id: UUID) -> PermissionType | None: + bucket = await self._buckets_repository.get_by_id(bucket_id) + if not bucket: + return None + + if bucket.owner_id == user_id: + return PermissionType.ADMIN + + permission = await self._bucket_permission_repository.get_by_bucket_and_user(bucket_id, user_id) + if permission: + return permission.permission_type + + if bucket.is_public: + return PermissionType.READ + + return None + + async def _get_file_and_check_access( + self, file_id: UUID, user_id: UUID, required_permission: PermissionType + ) -> File: + file = await self._files_repository.get_by_id(file_id) + if not file: + raise NotFound('File not found') + await self._access_service.check_bucket_access(user_id, file.bucket_id, required_permission) + return file + + async def get_file_metadata(self, file_id: UUID, user_id: UUID) -> File: + return await self._get_file_and_check_access(file_id, user_id, PermissionType.READ) + + async def rename_or_move_file( + self, + file_id: UUID, + user_id: UUID, + new_name: str | None = None, + new_folder_id: UUID | None = None, + move_to_root: bool = False, + ) -> File: + file = await self._get_file_and_check_access(file_id, user_id, PermissionType.WRITE) + + target_name = new_name if new_name is not None else file.name + + if move_to_root: + target_folder_id = None + elif new_folder_id is not None: + target_folder_id = new_folder_id + else: + target_folder_id = file.folder_id + + if target_folder_id and target_folder_id != file.folder_id: + folder = await self._folders_repository.get_by_id(target_folder_id) + if not folder: + raise NotFound('Target folder not found') + + if folder.bucket_id != file.bucket_id: + raise ClientError('Cannot move file to a folder in a different bucket') + + if target_name != file.original_filename or target_folder_id != file.folder_id: + existing = await self._files_repository.get_by_bucket_and_parent_and_name( + file.bucket_id, target_folder_id, target_name + ) + if existing and existing.id != file_id: + raise Conflict('File with this name already exists in the target location') + + file.original_filename = target_name + file.folder_id = target_folder_id + + return await self._files_repository.update(file) + + async def delete_file(self, file_id: UUID, user_id: UUID) -> None: + file = await self._get_file_and_check_access(file_id, user_id, PermissionType.WRITE) + + bucket = await self._buckets_repository.get_by_id(file.bucket_id) + if not bucket: + raise NotFound('Associated bucket not found') + + await self._minio_client.delete_object(bucket.minio_bucket_name, file.storage_filename) + + await self._files_repository.delete(file_id) + + owner = await self._users_repository.get_by_id(file.owner_id) + if owner: + owner.storage_used_bytes -= file.file_size_bytes + await self._users_repository.update(owner) + + async def get_download_link(self, file_id: UUID, user_id: UUID, expires_in: int = 3600) -> tuple[str, datetime]: + file = await self._get_file_and_check_access(file_id, user_id, PermissionType.READ) + + bucket = await self._buckets_repository.get_by_id(file.bucket_id) + if not bucket: + raise NotFound('Associated bucket not found') + + url, expires_at = await self._minio_client.get_presigned_download_url( + bucket.minio_bucket_name, file.storage_filename, expires_in=expires_in + ) + return url, expires_at + + async def get_recent_files(self, user_id: UUID, limit: int = 10) -> list[File]: + files = await self._files_repository.get_recent_by_owner(user_id, limit) + + bucket_ids = {f.bucket_id for f in files} + permission_map = {} + for bucket_id in bucket_ids: + permission_map[bucket_id] = await self._get_user_permission(user_id, bucket_id) + + for f in files: + f.permission = permission_map.get(f.bucket_id) + + return files + + async def get_files(self, user_id: UUID, bucket_id: UUID, folder_id: UUID | None) -> list[File]: + await self._access_service.check_bucket_access(user_id, bucket_id, PermissionType.READ) + + files = await self._files_repository.get_by_bucket_and_folder(bucket_id, folder_id) + + permission = await self._get_user_permission(user_id, bucket_id) + for f in files: + f.permission = permission + + return files diff --git a/src/services/folders/__init__.py b/src/services/folders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/folders/models.py b/src/services/folders/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/folders/service.py b/src/services/folders/service.py new file mode 100644 index 0000000..969af08 --- /dev/null +++ b/src/services/folders/service.py @@ -0,0 +1,179 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.database.repository import ( + UserRepository, + FolderRepository, + BucketRepository, + BucketPermissionRepository, + FileRepository, +) +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.folder.dtos import Folder +from src.exceptions import NotFound, ClientError, Conflict +from src.integrations.minio.client import MinioClient +from src.services.access.service import AccessService + + +class FoldersService: + def __init__( # noqa: PLR0913 + self, + users_repository: Annotated[UserRepository, Depends(UserRepository)], + folder_repository: Annotated[FolderRepository, Depends(FolderRepository)], + bucket_repository: Annotated[BucketRepository, Depends(BucketRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + file_repository: Annotated[FileRepository, Depends(FileRepository)], + minio_client: Annotated[MinioClient, Depends(MinioClient)], + access_service: Annotated[AccessService, Depends(AccessService)], + ): + self._user_repository = users_repository + self._folder_repository = folder_repository + self._bucket_repository = bucket_repository + self._bucket_permission_repository = bucket_permission_repository + self._file_repository = file_repository + self._minio_client = minio_client + self._access_service = access_service + + async def _get_user_permission(self, user_id: UUID, bucket_id: UUID) -> PermissionType | None: + bucket = await self._bucket_repository.get_by_id(bucket_id) + if not bucket: + return None + + if bucket.owner_id == user_id: + return PermissionType.ADMIN + + permission = await self._bucket_permission_repository.get_by_bucket_and_user(bucket_id, user_id) + if permission: + return permission.permission_type + + if bucket.is_public: + return PermissionType.READ + + return None + + async def _get_folder_and_check_access( + self, folder_id: UUID, user_id: UUID, required_permission: PermissionType + ) -> Folder: + folder = await self._folder_repository.get_by_id(folder_id) + if not folder: + raise NotFound('Folder not found') + await self._access_service.check_bucket_access(user_id, folder.bucket_id, required_permission) + return folder + + async def _is_descendant(self, folder_id: UUID, ancestor_id: UUID) -> bool: + return await self._folder_repository.is_descendant(folder_id, ancestor_id) + + # ---------- Основные методы ---------- + async def get_folder_info(self, folder_id: UUID, user_id: UUID) -> Folder: + return await self._get_folder_and_check_access(folder_id, user_id, PermissionType.READ) + + async def create_folder(self, name: str, bucket_id: UUID, parent_id: UUID | None, user_id: UUID) -> Folder: + await self._access_service.check_bucket_access(user_id, bucket_id, PermissionType.WRITE) + + depth = 0 + if parent_id: + parent = await self._folder_repository.get_by_id(parent_id) + if not parent: + raise NotFound('Parent folder not found') + if parent.bucket_id != bucket_id: + raise ClientError('Parent folder must belong to the same bucket') + depth = parent.depth + 1 + + existing = await self._folder_repository.get_by_parent_and_name(bucket_id, parent_id, name) + if existing: + raise Conflict('Folder with this name already exists in this location') + + folder = Folder( + name=name, + bucket_id=bucket_id, + parent_id=parent_id, + depth=depth, + ) + return await self._folder_repository.create(folder) + + async def rename_folder(self, folder_id: UUID, new_name: str, user_id: UUID) -> Folder: + folder = await self._get_folder_and_check_access(folder_id, user_id, PermissionType.WRITE) + + existing = await self._folder_repository.get_by_parent_and_name(folder.bucket_id, folder.parent_id, new_name) + if existing and existing.id != folder_id: + raise Conflict('Folder with this name already exists in this location') + + folder.name = new_name + return await self._folder_repository.update(folder) + + async def move_folder(self, folder_id: UUID, new_parent_id: UUID | None, user_id: UUID) -> Folder: + folder = await self._get_folder_and_check_access(folder_id, user_id, PermissionType.WRITE) + + new_parent = None + if new_parent_id: + new_parent = await self._folder_repository.get_by_id(new_parent_id) + if not new_parent: + raise NotFound('Target parent folder not found') + if new_parent.bucket_id != folder.bucket_id: + raise ClientError('Cannot move folder to a different bucket') + if new_parent_id == folder_id: + raise ClientError('Cannot move folder into itself') + + if await self._is_descendant(new_parent_id, folder_id): + raise ClientError('Cannot move folder into its own descendant') + + await self._access_service.check_bucket_access(user_id, new_parent.bucket_id, PermissionType.WRITE) + + new_depth = 0 if new_parent is None else new_parent.depth + 1 + delta_depth = new_depth - folder.depth + + existing = await self._folder_repository.get_by_parent_and_name(folder.bucket_id, new_parent_id, folder.name) + if existing and existing.id != folder_id: + raise Conflict('Folder with this name already exists in target location') + + folder.parent_id = new_parent_id + folder.depth = new_depth + await self._folder_repository.update(folder) + + if delta_depth != 0: + await self._folder_repository.update_subtree_depth(folder_id, delta_depth) + + return folder + + async def delete_folder(self, folder_id: UUID, user_id: UUID) -> None: + folder = await self._get_folder_and_check_access(folder_id, user_id, PermissionType.WRITE) + + subtree_ids = await self._folder_repository.get_subtree_ids(folder_id) + if not subtree_ids: + raise NotFound('Folder not found') + + files = await self._file_repository.get_by_folder_ids(subtree_ids) + + if files: + bucket = await self._bucket_repository.get_by_id(folder.bucket_id) + if not bucket: + raise NotFound('Associated bucket not found') + + await self._minio_client.delete_objects( + bucket_name=bucket.minio_bucket_name, + object_names=[file.storage_filename for file in files], + ) + + file_ids = [f.id for f in files] + await self._file_repository.delete_many(file_ids) + + total_size = sum(f.file_size_bytes for f in files) + owner = await self._user_repository.get_by_id(folder.owner_id) + if owner: + owner.storage_used_bytes -= total_size + await self._user_repository.update(owner) + + await self._folder_repository.delete_many(subtree_ids) + + async def get_folders(self, user_id: UUID, bucket_id: UUID, parent_id: UUID | None) -> list[Folder]: + await self._access_service.check_bucket_access(user_id, bucket_id, PermissionType.READ) + + folders = await self._folder_repository.get_by_bucket_and_parent(bucket_id, parent_id) + + permission = await self._get_user_permission(user_id, bucket_id) + for f in folders: + f.permission = permission + + return folders diff --git a/src/services/public_link/__init__.py b/src/services/public_link/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/public_link/models.py b/src/services/public_link/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/public_link/service.py b/src/services/public_link/service.py new file mode 100644 index 0000000..4ce3f83 --- /dev/null +++ b/src/services/public_link/service.py @@ -0,0 +1,271 @@ +from datetime import datetime, UTC, timedelta +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.core.config import config +from src.database.repository import ( + PublicLinkRepository, + FileRepository, + FolderRepository, + BucketRepository, + BucketPermissionRepository, +) +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.public_link.dtos import PublicLink +from src.exceptions import NotFound, ClientError, PermissionDenied +from src.handlers.api.v1.public_links.models import CreatePublicLinkResponse, PublicLinkListResponse, \ + PublicLinkDetailResponse +from src.integrations.minio.client import MinioClient +from src.services.access.service import AccessService + + +class PublicLinkService: + def __init__( # noqa: PLR0913 + self, + public_link_repository: Annotated[PublicLinkRepository, Depends(PublicLinkRepository)], + file_repository: Annotated[FileRepository, Depends(FileRepository)], + folder_repository: Annotated[FolderRepository, Depends(FolderRepository)], + bucket_repository: Annotated[BucketRepository, Depends(BucketRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + minio_client: Annotated[MinioClient, Depends(MinioClient)], + access_service: Annotated[AccessService, Depends(AccessService)], + ): + self._public_link_repository = public_link_repository + self._file_repository = file_repository + self._folder_repository = folder_repository + self._bucket_repository = bucket_repository + self._bucket_permission_repository = bucket_permission_repository + self._minio_client = minio_client + self._access_service = access_service + + def _get_public_base_url(self) -> str: + scheme = 'https' if config.SECURE else 'http' + return f'{scheme}://{config.EXTERNAL_ADDRESS}' + + def _generate_public_link_url(self, link_id: UUID) -> str: + base_url = self._get_public_base_url() + return f'{base_url}/public/download/{link_id}' + + async def create_link_with_url( + self, + actor_user_id: UUID, + file_id: UUID | None, + folder_id: UUID | None, + expires_in_seconds: int | None, + max_downloads: int | None, + ) -> dict: + link = await self.create_link( + actor_user_id=actor_user_id, + file_id=file_id, + folder_id=folder_id, + expires_in_seconds=expires_in_seconds, + max_downloads=max_downloads, + ) + + result = CreatePublicLinkResponse.model_validate(link).model_dump(by_alias=True) + result['url'] = self._generate_public_link_url(link.id) + return result + + async def list_links_with_urls( + self, + actor_user_id: UUID, + file_id: UUID | None, + folder_id: UUID | None, + ) -> list[dict]: + links = await self.list_links( + actor_user_id=actor_user_id, + file_id=file_id, + folder_id=folder_id, + ) + + result = PublicLinkListResponse.model_validate(links).model_dump(by_alias=True) + for link in result: + link['url'] = self._generate_public_link_url(link['id']) + return result + + async def get_link_with_url(self, actor_user_id: UUID, link_id: UUID) -> dict: + link = await self.get_link(actor_user_id=actor_user_id, link_id=link_id) + + result = PublicLinkDetailResponse.model_validate(link).model_dump(by_alias=True) + result['url'] = self._generate_public_link_url(link.id) + return result + + async def _check_resource_access( + self, + user_id: UUID, + file_id: UUID | None = None, + folder_id: UUID | None = None, + required_permission: PermissionType = PermissionType.READ, + ) -> UUID: + bucket_id = None + if file_id: + file = await self._file_repository.get_by_id(file_id) + if not file: + raise NotFound('File not found') + bucket_id = file.bucket_id + elif folder_id: + folder = await self._folder_repository.get_by_id(folder_id) + if not folder: + raise NotFound('Folder not found') + bucket_id = folder.bucket_id + else: + raise ClientError('No resource specified') + + bucket = await self._bucket_repository.get_by_id(bucket_id) + if not bucket: + raise NotFound('Associated bucket not found') + + if bucket.owner_id == user_id: + return bucket_id + + if bucket.is_public and required_permission == PermissionType.READ: + return bucket_id + + permission = await self._bucket_permission_repository.get_by_bucket_and_user(bucket_id, user_id) + if not permission: + raise PermissionDenied('Access denied to this bucket') + + rank = {PermissionType.READ: 1, PermissionType.WRITE: 2, PermissionType.ADMIN: 3} + if rank[permission.permission_type] < rank[required_permission]: + raise PermissionDenied('Insufficient permissions for this operation') + + return bucket_id + + async def create_link( + self, + actor_user_id: UUID, + file_id: UUID | None, + folder_id: UUID | None, + expires_in_seconds: int | None, + max_downloads: int | None, + ) -> PublicLink: + await self._check_resource_access( + user_id=actor_user_id, + file_id=file_id, + folder_id=folder_id, + required_permission=PermissionType.WRITE, + ) + + expires_at = None + if expires_in_seconds is not None: + expires_at = datetime.now(UTC) + timedelta(seconds=expires_in_seconds) + + link = PublicLink( + file_id=file_id, + folder_id=folder_id, + expires_at=expires_at, + max_downloads=max_downloads, + downloads_count=0, + ) + return await self._public_link_repository.create(link) + + async def list_links( + self, + actor_user_id: UUID, + file_id: UUID | None, + folder_id: UUID | None, + ) -> list[PublicLink]: + if (file_id is None) == (folder_id is None): + raise ClientError('Exactly one of file_id or folder_id must be provided') + + await self._check_resource_access( + user_id=actor_user_id, + file_id=file_id, + folder_id=folder_id, + required_permission=PermissionType.READ, + ) + + if file_id: + links = await self._public_link_repository.get_by_file(file_id) + else: + links = await self._public_link_repository.get_by_folder(folder_id) + + return links + + async def get_link(self, actor_user_id: UUID, link_id: UUID) -> PublicLink: + link = await self._public_link_repository.get_by_id(link_id) + if not link: + raise NotFound('Public link not found') + + await self._check_resource_access( + user_id=actor_user_id, + file_id=link.file_id, + folder_id=link.folder_id, + required_permission=PermissionType.WRITE, + ) + + return link + + async def delete_link(self, actor_user_id: UUID, link_id: UUID) -> None: + link = await self._public_link_repository.get_by_id(link_id) + if not link: + raise NotFound('Public link not found') + + await self._check_resource_access( + user_id=actor_user_id, + file_id=link.file_id, + folder_id=link.folder_id, + required_permission=PermissionType.WRITE, + ) + + await self._public_link_repository.delete(link_id) + + async def get_public_link_info(self, link_id: UUID) -> dict: + link = await self._public_link_repository.get_by_id(link_id) + if not link: + raise NotFound('Public link not found') + + if link.expires_at and link.expires_at < datetime.now(UTC): + raise ClientError('Link expired', code='link_expired') + + if link.max_downloads and link.downloads_count >= link.max_downloads: + raise ClientError('Download limit exceeded', code='download_limit_exceeded') + + file_name = None + if link.file_id: + file = await self._file_repository.get_by_id(link.file_id) + if file: + file_name = file.original_filename + + return { + 'id': link.id, + 'fileName': file_name, + 'downloadsCount': link.downloads_count, + 'maxDownloads': link.max_downloads, + 'expiresAt': link.expires_at, + } + + async def get_public_download_url(self, link_id: UUID) -> str: + link = await self._public_link_repository.get_by_id(link_id) + if not link: + raise NotFound('Public link not found') + + if link.expires_at and link.expires_at < datetime.now(UTC): + raise ClientError('Link expired', code='link_expired') + + if link.max_downloads and link.downloads_count >= link.max_downloads: + raise ClientError('Download limit exceeded', code='download_limit_exceeded') + + if not link.file_id: + raise ClientError('Folder links not supported for download', code='folder_not_supported') + + file = await self._file_repository.get_by_id(link.file_id) + if not file: + raise NotFound('File not found') + + bucket = await self._bucket_repository.get_by_id(file.bucket_id) + if not bucket: + raise NotFound('Bucket not found') + + url, _ = await self._minio_client.get_presigned_download_url( + bucket_name=bucket.minio_bucket_name, + object_name=file.storage_filename, + expires_in=3600, + ) + + link.downloads_count += 1 + await self._public_link_repository.update(link) + + return url diff --git a/src/services/search/__init__.py b/src/services/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/search/models.py b/src/services/search/models.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/search/service.py b/src/services/search/service.py new file mode 100644 index 0000000..8279074 --- /dev/null +++ b/src/services/search/service.py @@ -0,0 +1,65 @@ +from typing import Annotated, Literal +from uuid import UUID + +from fastapi import Depends + +from src.database.repository import BucketRepository, FolderRepository, FileRepository, BucketPermissionRepository +from src.database.repository.postgres.bucket.dtos import Bucket +from src.database.repository.postgres.file.dtos import File +from src.database.repository.postgres.folder.dtos import Folder +from src.exceptions import PermissionDenied + + +class SearchService: + def __init__( + self, + bucket_repository: Annotated[BucketRepository, Depends(BucketRepository)], + folder_repository: Annotated[FolderRepository, Depends(FolderRepository)], + file_repository: Annotated[FileRepository, Depends(FileRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + ): + self._bucket_repository = bucket_repository + self._folder_repository = folder_repository + self._file_repository = file_repository + self._bucket_permission_repository = bucket_permission_repository + + async def _get_accessible_bucket_ids(self, user_id: UUID, bucket_id: UUID | None = None) -> list[UUID]: + accessible = await self._bucket_repository.get_accessible_buckets(user_id) + accessible_ids = [b.id for b in accessible] + + if bucket_id: + if bucket_id not in accessible_ids: + raise PermissionDenied('Bucket not accessible') + return [bucket_id] + return accessible_ids + + async def search( + self, + user_id: UUID, + query: str, + search_type: Literal['bucket', 'folder', 'file'] | None = None, + bucket_id: UUID | None = None, + ) -> tuple[list[Bucket], list[Folder], list[File]]: + bucket_ids = await self._get_accessible_bucket_ids(user_id, bucket_id) + + buckets: list[Bucket] = [] + folders: list[Folder] = [] + files: list[File] = [] + + bucket_stats = {} + if bucket_ids: + bucket_stats = await self._file_repository.get_buckets_stats(bucket_ids) + + if (search_type is None or search_type == 'bucket') and bucket_id is None: + buckets = await self._bucket_repository.search_by_name(bucket_ids, query) + for bucket in buckets: + if bucket.id in bucket_stats: + bucket.files_count, bucket.size = bucket_stats[bucket.id] + + if search_type is None or search_type == 'folder': + folders = await self._folder_repository.search_by_name(bucket_ids, query) + + if search_type is None or search_type == 'file': + files = await self._file_repository.search_by_name(bucket_ids, query) + + return buckets, folders, files diff --git a/src/services/security/__init__.py b/src/services/security/__init__.py new file mode 100644 index 0000000..3d8f9b1 --- /dev/null +++ b/src/services/security/__init__.py @@ -0,0 +1,5 @@ +from src.services.security.service import JWTHandler + +__all__ = [ + 'JWTHandler', +] \ No newline at end of file diff --git a/src/services/security/service.py b/src/services/security/service.py new file mode 100644 index 0000000..82ad63a --- /dev/null +++ b/src/services/security/service.py @@ -0,0 +1,56 @@ +from datetime import datetime, UTC, timedelta +from typing import Any, Literal +from jose import jwt +from uuid import UUID + +from src.core.config import config + + +class JWTHandler: + """Handler for JWT tokens""" + + def get_access_token(self, user_id: UUID) -> tuple[str, datetime]: + return self._create_jwt_token(user_id, "access", expires_at=datetime.now(UTC) + timedelta(minutes=config.ACCESS_TOKEN_EXP_MIN)) + + def get_refresh_token(self, user_id: UUID) -> tuple[str, datetime]: + return self._create_jwt_token(user_id, "refresh", expires_at=datetime.now(UTC) + timedelta(minutes=config.REFRESH_TOKEN_EXP_MIN)) + + @staticmethod + def _create_jwt_token(user_id: UUID, token_type: Literal['access', 'refresh'], expires_at: datetime) -> tuple[str, datetime]: + """Create jwt token""" + jwt_data = { + 'sub': str(user_id), + 'type': token_type, + 'exp': int(expires_at.timestamp()), + } + + token = jwt.encode(jwt_data, config.JWT_SECRET.get_secret_value(), algorithm=config.JWT_ALGORITHM) + return token, expires_at + + @staticmethod + def decode_token(token: str) -> dict[str, Any]: + """Decode JWT token""" + return jwt.decode(token, config.JWT_SECRET.get_secret_value(), algorithms=[config.JWT_ALGORITHM]) + + @staticmethod + def is_token_expired(token: str | dict[str, Any]) -> bool: + try: + payload = JWTHandler.decode_token(token) if isinstance(token, str) else token + exp = payload.get('exp') + if not exp: + return True + + return exp < int(datetime.now(UTC).timestamp()) + + except Exception: + return True + + @staticmethod + def get_token_expiration(token: str) -> datetime: + """Get token expiration time""" + payload = JWTHandler.decode_token(token) + exp = payload.get('exp') + if not exp: + raise ValueError('Token has no expiration time') + + return datetime.fromtimestamp(exp, tz=UTC) diff --git a/src/services/ses/__init__.py b/src/services/ses/__init__.py new file mode 100644 index 0000000..c2a82e7 --- /dev/null +++ b/src/services/ses/__init__.py @@ -0,0 +1,5 @@ +from src.services.ses.yandex import YandexSESService + +__all__ = [ + 'YandexSESService', +] \ No newline at end of file diff --git a/src/services/ses/base.py b/src/services/ses/base.py new file mode 100644 index 0000000..aaf2b64 --- /dev/null +++ b/src/services/ses/base.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod + + +class AbstractEmailService(ABC): + """Interface for email service""" + + @abstractmethod + async def send_email(self, to_email: str | list[str], subject: str, body: str) -> None: + """ + Send email + :param to_email: Recipient email or list of emails + :param subject: Email subject + :param body: Email body + """ + raise NotImplementedError diff --git a/src/services/ses/templates.py b/src/services/ses/templates.py new file mode 100644 index 0000000..33415f6 --- /dev/null +++ b/src/services/ses/templates.py @@ -0,0 +1,182 @@ +import html +from datetime import datetime +from typing import Protocol + + +class EmailTemplate(Protocol): + """Protocol for email templates""" + + subject: str + body: str + html: bool = False + + def render(self) -> str: + """Render template to string""" + ... + + +class TwoFactorAuthTemplate: + def __init__(self, code: str): + self.subject = f'Your verification code: {code}' + self.html = True + self.body = f""" + + + NetVault Auth + + + + +
+

Email Verification

+ +

Welcome,

+ +

To complete your sign-in, use the verification code below:

+ +
+ {code} +
+ +
+

• This code will expire in 10 minutes.

+

• If you didn't request this code, please ignore this email.

+
+
+ + + """ + + def render(self) -> str: + """Render template to string""" + return self.body + + +class BucketAccessChangeTemplate: + def __init__( + self, + bucket_name: str, + permission: str, + granted_by: str, + date: str | None = None + ): + self.bucket_name = html.escape(bucket_name) + self.permission = html.escape(permission) + self.granted_by = html.escape(granted_by) + if date is None: + date = datetime.now().strftime("%Y-%m-%d %H:%M") + self.date = html.escape(date) + + self.subject = f'Access to bucket {self.bucket_name} has been updated' + self.html = True + + def render(self) -> str: + return f""" + + + NetVault Access Update + + + + +
+

Bucket Access Update

+ +

Hello

+ +

The access permissions for bucket {self.bucket_name} have been updated.

+ +
+

New permission: {self.permission}

+

Granted by: {self.granted_by}

+

Date: {self.date}

+
+ +
+

• If you didn't expect this change, please contact your administrator.

+
+
+ + + """ \ No newline at end of file diff --git a/src/services/ses/yandex.py b/src/services/ses/yandex.py new file mode 100644 index 0000000..5fbf918 --- /dev/null +++ b/src/services/ses/yandex.py @@ -0,0 +1,105 @@ +import re +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import aioboto3 +from botocore.exceptions import ClientError + +from src.core.config import config +from src.core.logger import get_logger + +from src.services.ses.base import AbstractEmailService +from src.services.ses.templates import TwoFactorAuthTemplate, BucketAccessChangeTemplate + +logger = get_logger(__name__) + + +class YandexSESService(AbstractEmailService): + def __init__(self): + self.session = aioboto3.Session() + self._endpoint = config.YC_POSTBOX_ENDPOINT + self._region = config.YC_POSTBOX_REGION + self._sender = config.MAIL_FROM + self._access_key = config.YC_POSTBOX_ACCESS_KEY + self._secret_key = config.YC_POSTBOX_SECRET_KEY.get_secret_value() + + @staticmethod + def _html_to_text(html: str) -> str: + return re.sub(r'<[^>]+>', '', html).strip() + + async def send_email( + self, + to_email: str | list[str], + subject: str, + html_body: str, + text_body: str | None = None, + charset: str = "UTF-8" + ) -> None: + destinations = [to_email] if isinstance(to_email, str) else to_email + text_body = text_body or self._html_to_text(html_body) + + msg = MIMEMultipart('mixed') + msg['Subject'] = subject + msg['From'] = self._sender + msg['To'] = ', '.join(destinations) + + msg_body = MIMEMultipart('alternative') + text_part = MIMEText(text_body.encode(charset), 'plain', charset) + html_part = MIMEText(html_body.encode(charset), 'html', charset) + msg_body.attach(text_part) + msg_body.attach(html_part) + msg.attach(msg_body) + + raw_message = str(msg) + raw_message_bytes = bytes(raw_message, charset) + + try: + async with self.session.client( + 'sesv2', + region_name=self._region, + endpoint_url=self._endpoint, + aws_access_key_id=self._access_key, + aws_secret_access_key=self._secret_key, + ) as client: + await client.send_email( + FromEmailAddress=self._sender, + Destination={'ToAddresses': destinations}, + Content={'Raw': {'Data': raw_message_bytes}} + ) + + except ClientError as e: + error_message = e.response.get('Error', {}).get('Message', 'Unknown error') + logger.error(f"Yandex Postbox ClientError: {error_message}") # noqa: G004 + raise + except Exception as e: + logger.exception(f"Unexpected error in Postbox: {e}") # noqa: G004 + raise + + async def send_verification_email(self, user_email: str, token: str) -> None: + template = TwoFactorAuthTemplate(code=token) + await self.send_email( + to_email=user_email, + subject=f"Your Code: {token}", + html_body=template.render(), + ) + + async def send_bucket_permission_changed_email( + self, + user_email: str, + bucket_name: str, + permission: str, + granted_by: str, + date: str | None = None + ) -> None: + template = BucketAccessChangeTemplate( + bucket_name=bucket_name, + permission=permission, + granted_by=granted_by, + date=date + ) + + await self.send_email( + to_email=user_email, + subject=template.subject, + html_body=template.render(), + ) \ No newline at end of file diff --git a/src/services/upload_sessions/__init__.py b/src/services/upload_sessions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/upload_sessions/service.py b/src/services/upload_sessions/service.py new file mode 100644 index 0000000..5d390b6 --- /dev/null +++ b/src/services/upload_sessions/service.py @@ -0,0 +1,392 @@ +import uuid +from datetime import datetime, timedelta, UTC +from typing import Annotated +from uuid import UUID + +from fastapi import Depends + +from src.core.config import config +from src.database.repository import ( + UserRepository, + BucketRepository, + FolderRepository, + BucketPermissionRepository, + FileRepository, +) +from src.database.repository.postgres.bucket_permission.dtos import PermissionType +from src.database.repository.postgres.file.dtos import File +from src.exceptions import ClientError, NotFound, PermissionDenied +from src.handlers.api.v1.upload_sessions.models import ( + InitUploadResponse, + SimpleUploadResponse, + MultipartUploadResponse, + UploadStatusResponse, + CompleteUploadResponse, +) +from src.integrations.minio.client import MinioClient + +from src.integrations.redis import RedisClient + + +class UploadSessionsService: + def __init__( # noqa: PLR0913 + self, + users_repository: Annotated[UserRepository, Depends(UserRepository)], + buckets_repository: Annotated[BucketRepository, Depends(BucketRepository)], + folders_repository: Annotated[FolderRepository, Depends(FolderRepository)], + bucket_permission_repository: Annotated[BucketPermissionRepository, Depends(BucketPermissionRepository)], + files_repository: Annotated[FileRepository, Depends(FileRepository)], + minio_client: Annotated[MinioClient, Depends(MinioClient)], + redis_client: Annotated[RedisClient, Depends(RedisClient)], + ): + self._users_repo = users_repository + self._buckets_repo = buckets_repository + self._folders_repo = folders_repository + self._perms_repo = bucket_permission_repository + self._files_repo = files_repository + self._minio = minio_client + self._redis = redis_client + + self._threshold = config.UPLOAD_THRESHOLD + self._chunk_size = config.UPLOAD_CHUNK_SIZE + + async def _check_bucket_access(self, user_id: UUID, bucket_id: UUID, required_permission: PermissionType) -> None: + bucket = await self._buckets_repo.get_by_id(bucket_id) + if not bucket: + raise NotFound('Bucket not found') + + if bucket.owner_id == user_id: + return + + if bucket.is_public and required_permission == PermissionType.READ: + return + + permission = await self._perms_repo.get_by_bucket_and_user(bucket_id, user_id) + if not permission: + raise PermissionDenied('Access denied to this bucket') + + rank = {PermissionType.READ: 1, PermissionType.WRITE: 2, PermissionType.ADMIN: 3} + if rank[permission.permission_type] < rank[required_permission]: + raise PermissionDenied('Insufficient permissions') + + async def _build_file_path(self, folder_id: UUID | None, filename: str) -> str: + if folder_id is None: + return filename + + path_parts = [] + current_id = folder_id + while current_id: + folder = await self._folders_repo.get_by_id(current_id) + if not folder: + break + path_parts.insert(0, folder.name) + current_id = folder.parent_id + path_parts.append(filename) + return '/'.join(path_parts) + + async def _check_folder(self, folder_id: UUID | None, bucket_id: UUID) -> None: + if folder_id is None: + return + folder = await self._folders_repo.get_by_id(folder_id) + if not folder: + raise NotFound('Folder not found') + if folder.bucket_id != bucket_id: + raise ClientError('Folder does not belong to the specified bucket') + + async def _reserve_quota(self, user_id: UUID, size: int) -> None: + user = await self._users_repo.get_by_id(user_id) + if not user: + raise NotFound('User not found') + if user.storage_used_bytes + user.storage_reserved_bytes + size > user.storage_quota_bytes: + raise ClientError('Insufficient storage quota') + user.storage_reserved_bytes += size + await self._users_repo.update(user) + + async def _release_quota(self, user_id: UUID, size: int) -> None: + user = await self._users_repo.get_by_id(user_id) + if user: + user.storage_reserved_bytes -= size + await self._users_repo.update(user) + + async def _apply_quota(self, user_id: UUID, size: int) -> None: + user = await self._users_repo.get_by_id(user_id) + if user: + user.storage_reserved_bytes -= size + user.storage_used_bytes += size + await self._users_repo.update(user) + + async def _get_bucket_name(self, bucket_id: UUID) -> str: + bucket = await self._buckets_repo.get_by_id(bucket_id) + if not bucket: + raise NotFound('Bucket not found') + return bucket.minio_bucket_name + + async def init_upload( # noqa: PLR0913 + self, + actor_user_id: UUID, + bucket_id: UUID, + folder_id: UUID | None, + name: str, + size: int, + mime_type: str, + ) -> InitUploadResponse: + await self._check_bucket_access(actor_user_id, bucket_id, PermissionType.WRITE) + await self._check_folder(folder_id, bucket_id) + + await self._reserve_quota(actor_user_id, size) + + object_name = f'{uuid.uuid4()}/{name}' + + session_id = uuid.uuid4() + expires_at = datetime.now(UTC) + timedelta(hours=24) + path = await self._build_file_path(folder_id, name) + + if size < self._threshold: + url = await self._minio.presigned_put_object( + bucket_name=await self._get_bucket_name(bucket_id), + object_name=object_name, + expires=timedelta(hours=24), + ) + meta = { + 'type': 'simple', + 'user_id': str(actor_user_id), + 'bucket_id': str(bucket_id), + 'folder_id': str(folder_id) if folder_id else 'None', + 'name': name, + 'path': path, + 'object_name': object_name, + 'size': size, + 'mime_type': mime_type, + 'status': 'pending', + 'expires_at': expires_at.isoformat(), + } + await self._redis.hset(f'upload:{session_id}:meta', mapping=meta) + await self._redis.expire(f'upload:{session_id}:meta', 86400) + + return SimpleUploadResponse( + sessionId=session_id, + uploadType='simple', + uploadUrl=url, + expiresAt=expires_at, + ) + bucket_name = await self._get_bucket_name(bucket_id) + total_parts = (size + self._chunk_size - 1) // self._chunk_size + try: + upload_id = await self._minio.create_multipart_upload(bucket_name, object_name) + except Exception: + await self._release_quota(actor_user_id, size) + raise + + part_urls = {} + for i in range(1, total_parts + 1): + url = await self._minio.presigned_put_part_url( + bucket_name=bucket_name, + object_name=object_name, + upload_id=upload_id, + part_number=i, + expires=timedelta(hours=24), + ) + part_urls[i] = url + + meta = { + 'type': 'multipart', + 'user_id': str(actor_user_id), + 'bucket_id': str(bucket_id), + 'folder_id': str(folder_id) if folder_id else 'None', + 'name': name, + 'path': path, + 'object_name': object_name, + 'size': size, + 'mime_type': mime_type, + 'upload_id': upload_id, + 'total_parts': total_parts, + 'chunk_size': self._chunk_size, + 'status': 'active', + 'expires_at': expires_at.isoformat(), + } + await self._redis.hset(f'upload:{session_id}:meta', mapping=meta) + await self._redis.expire(f'upload:{session_id}:meta', 86400) + await self._redis.hset(f'upload:{session_id}:etags', mapping={'_init': '1'}) + await self._redis.expire(f'upload:{session_id}:etags', 86400) + + return MultipartUploadResponse( + sessionId=session_id, + uploadType='multipart', + partUrls=part_urls, + chunkSize=self._chunk_size, + totalParts=total_parts, + expiresAt=expires_at, + ) + + async def complete_part(self, actor_user_id: UUID, session_id: UUID, part_number: int, etag: str) -> None: + meta = await self._redis.hgetall(f'upload:{session_id}:meta') + if not meta: + raise NotFound('Upload session not found') + + if meta.get('type') != 'multipart': + raise ClientError('This operation is only for multipart uploads') + + if UUID(meta['user_id']) != actor_user_id: + raise PermissionDenied('Access denied') + + if meta['status'] != 'active': + raise ClientError('Session is not active') + + await self._redis.sadd(f'upload:{session_id}:parts', part_number) + await self._redis.hset(f'upload:{session_id}:etags', {str(part_number): etag}) + + async def get_upload_status(self, actor_user_id: UUID, session_id: UUID) -> UploadStatusResponse: + meta = await self._redis.hgetall(f'upload:{session_id}:meta') + if not meta: + raise NotFound('Upload session not found') + + if UUID(meta['user_id']) != actor_user_id: + raise PermissionDenied('Access denied') + + expires_at = datetime.fromisoformat(meta['expires_at']) + if meta['type'] == 'simple': + return UploadStatusResponse( + sessionId=session_id, + uploadType='simple', + status=meta['status'], + expiresAt=expires_at, + completedParts=None, + totalParts=None, + ) + completed_parts = await self._redis.scard(f'upload:{session_id}:parts') + return UploadStatusResponse( + sessionId=session_id, + uploadType='multipart', + status=meta['status'], + completedParts=completed_parts, + totalParts=int(meta['total_parts']), + expiresAt=expires_at, + ) + + async def complete_upload(self, actor_user_id: UUID, session_id: UUID) -> CompleteUploadResponse: + meta = await self._redis.hgetall(f'upload:{session_id}:meta') + if not meta: + raise NotFound('Upload session not found') + + if UUID(meta['user_id']) != actor_user_id: + raise PermissionDenied('Access denied') + + bucket_name = await self._get_bucket_name(UUID(meta['bucket_id'])) + folder_id = None + if meta.get('folder_id') and meta['folder_id'] != 'None': + folder_id = UUID(meta['folder_id']) + + if meta['type'] == 'simple': + if meta['status'] != 'pending': + raise ClientError('Invalid session status') + + try: + await self._minio.stat_object(bucket_name, meta['object_name']) + except Exception as exc: + raise ClientError('File not uploaded to storage') from exc + + file = File( + original_filename=meta['name'], + bucket_id=UUID(meta['bucket_id']), + folder_id=folder_id, + owner_id=actor_user_id, + file_size_bytes=int(meta['size']), + mime_type=meta['mime_type'], + storage_filename=meta['object_name'], + path=meta['path'], + file_hash=None, + ) + created = await self._files_repo.create(file) + + await self._apply_quota(actor_user_id, created.file_size_bytes) + + await self._redis.delete(f'upload:{session_id}:meta') + + return CompleteUploadResponse( + fileId=created.id, + name=created.original_filename, + size=created.file_size_bytes, + mimeType=created.mime_type, + bucketId=created.bucket_id, + folderId=created.folder_id, + uploadedAt=created.created_at, + ) + if meta['status'] != 'active': + raise ClientError('Invalid session status', code='invalid_session_data') + + total_parts = int(meta['total_parts']) + completed_parts = await self._redis.scard(f'upload:{session_id}:parts') + if completed_parts != total_parts: + raise ClientError('Not all parts uploaded', code='not_all_parts_uploaded') + + etags = await self._redis.hgetall(f'upload:{session_id}:etags') + parts = [] + for i in range(1, total_parts + 1): + etag = etags.get(str(i)) + if not etag: + raise ClientError(f'Missing etag for part {i}') + parts.append({'PartNumber': i, 'ETag': etag}) + + await self._minio.complete_multipart_upload( + bucket_name=bucket_name, + object_name=meta['object_name'], + upload_id=meta['upload_id'], + parts=parts, + ) + + file = File( + original_filename=meta['name'], + bucket_id=UUID(meta['bucket_id']), + folder_id=folder_id, + owner_id=actor_user_id, + file_size_bytes=int(meta['size']), + mime_type=meta['mime_type'], + storage_filename=meta['object_name'], + path=meta['path'], + file_hash=None, + ) + created = await self._files_repo.create(file) + + await self._apply_quota(actor_user_id, created.file_size_bytes) + + for key in [ + f'upload:{session_id}:meta', + f'upload:{session_id}:parts', + f'upload:{session_id}:etags', + ]: + await self._redis.delete(key) + + return CompleteUploadResponse( + fileId=created.id, + name=created.original_filename, + size=created.file_size_bytes, + mimeType=created.mime_type, + bucketId=created.bucket_id, + folderId=created.folder_id, + uploadedAt=created.created_at, + ) + + async def abort_upload(self, actor_user_id: UUID, session_id: UUID) -> None: + meta = await self._redis.hgetall(f'upload:{session_id}:meta') + if not meta: + raise NotFound('Upload session not found') + + if UUID(meta['user_id']) != actor_user_id: + raise PermissionDenied('Access denied') + + if meta['type'] == 'multipart' and meta['status'] == 'active': + bucket_name = await self._get_bucket_name(UUID(meta['bucket_id'])) + await self._minio.abort_multipart_upload( + bucket_name=bucket_name, + object_name=meta['object_name'], + upload_id=meta['upload_id'], + ) + + await self._release_quota(actor_user_id, int(meta['size'])) + + for key in [ + f'upload:{session_id}:meta', + f'upload:{session_id}:parts', + f'upload:{session_id}:etags', + ]: + await self._redis.delete(key) diff --git a/src/templates/download_page.html b/src/templates/download_page.html new file mode 100644 index 0000000..fb05114 --- /dev/null +++ b/src/templates/download_page.html @@ -0,0 +1,252 @@ + + + + + + Download File - NetVault + + + +
+ + +
+ + + +
+ +
Loading...
+
+ + + + + + + + + + +
+
+ Loading... +
+
+ + + + diff --git a/src/utils/config.py b/src/utils/config.py deleted file mode 100644 index 4f10934..0000000 --- a/src/utils/config.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -from dataclasses import dataclass - - -@dataclass -class Config: - """Конфигурация приложения""" - - host: str = str(os.getenv('HOST', 'localhost')) - port: int = int(os.getenv('PORT', '8000')) - - log_to_file: bool = bool(os.getenv('LOG_TO_FILE', 'False')) - - chunk_size: int = int(os.getenv('CHUNK_SIZE', '65536')) # 64 KB - max_file_size: int = int(os.getenv('MAX_FILE_SIZE', '1073741824')) # 1 GB - - connection_timeout: float = float(os.getenv('CONNECTION_TIMEOUT', '30.0')) - read_timeout: float = float(os.getenv('READ_TIMEOUT', '300.0')) - - min_password_length: int = int(os.getenv('MIN_PASSWORD_LENGTH', '6')) - max_login_length: int = int(os.getenv('MAX_LOGIN_LENGTH', '50')) - max_path_length: int = int(os.getenv('MAX_PATH_LENGTH', '4096')) - - -config = Config() diff --git a/src/utils/constants.py b/src/utils/constants.py deleted file mode 100644 index 6e8f7bf..0000000 --- a/src/utils/constants.py +++ /dev/null @@ -1,9 +0,0 @@ -from pathlib import Path - -# Project paths -PROJECT_ROOT = Path(__file__).parent.parent.parent -STORAGE_DIR = PROJECT_ROOT / 'storage' -USERS_FILE = PROJECT_ROOT / 'users.json' - -# Ensure storage directory exists -STORAGE_DIR.mkdir(exist_ok=True) diff --git a/src/utils/exceptions.py b/src/utils/exceptions.py deleted file mode 100644 index de8973f..0000000 --- a/src/utils/exceptions.py +++ /dev/null @@ -1,34 +0,0 @@ -class StorageError(Exception): - """Базовое исключение для ошибок хранилища""" - - pass - - -class StorageConnectionError(StorageError): - """Ошибка подключения к серверу""" - - pass - - -class AuthenticationError(StorageError): - """Ошибка авторизации""" - - pass - - -class FileError(StorageError): - """Ошибка при работе с файлом""" - - pass - - -class ProtocolError(StorageError): - """Ошибка протокола обмена данными""" - - pass - - -class ValidationError(StorageError): - """Ошибка валидации входных данных""" - - pass diff --git a/src/utils/logger.py b/src/utils/logger.py deleted file mode 100644 index 2442331..0000000 --- a/src/utils/logger.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -import sys - -from src.utils.constants import PROJECT_ROOT -from src.utils.config import config - - -def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger: - """Настраивает и возвращает логгер""" - logger = logging.getLogger(name) - logger.setLevel(level) - - if logger.handlers: - return logger - - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S') - - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setLevel(level) - console_handler.setFormatter(formatter) - logger.addHandler(console_handler) - - if config.log_to_file: - log_file = PROJECT_ROOT / 'logs' / 'app.log' - log_file.parent.mkdir(exist_ok=True) - file_handler = logging.FileHandler(log_file) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - return logger - - -server_logger = setup_logger('server') -client_logger = setup_logger('client') diff --git a/src/utils/security.py b/src/utils/security.py deleted file mode 100644 index 70418c2..0000000 --- a/src/utils/security.py +++ /dev/null @@ -1,16 +0,0 @@ -import bcrypt - - -def hash_password(password: str) -> str: - """Хеширует пароль с использованием bcrypt""" - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(password.encode('utf-8'), salt) - return hashed.decode('utf-8') - - -def verify_password(password: str, hashed: str) -> bool: - """Сравнивает пароль и хеш""" - try: - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - except Exception: - return False diff --git a/src/utils/types.py b/src/utils/types.py deleted file mode 100644 index 0dfcc93..0000000 --- a/src/utils/types.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import TypedDict, Literal - - -class FileInfo(TypedDict): - """Информация о файле или директории""" - - name: str - type: Literal['file', 'directory'] - size: int - - -class CommandResponse(TypedDict, total=False): - """Ответ сервера на команду""" - - status: Literal['OK', 'ERROR'] - message: str - data: dict - files: list[FileInfo] - filename: str - size: int - uuid: str - - -class CommandRequest(TypedDict, total=False): - """Запрос команды к серверу""" - - command: Literal['REGISTER', 'AUTH', 'LIST', 'GET', 'PUT', 'DELETE'] - login: str - password: str - path: str - size: int diff --git a/test.py b/test.py deleted file mode 100644 index efe0e72..0000000 --- a/test.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Скрипт проверяет качество передачи данных и позволяет найти ошибку в расхождении исходных и переданных данных -""" - -import hashlib -import os -import sys -from pathlib import Path - - -def verify_files_by_content(file_paths): - """ - Проверяет, что все файлы имеют идентичное содержимое - """ - print('\n🔍 Проверка совпадения содержимого файлов...') - - if len(file_paths) < 2: - print('❌ Нужно как минимум 2 файла для сравнения') - return False - - file_contents = [] - for file_path in file_paths: - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - file_contents.append((file_path, content)) - print(f'📁 {os.path.basename(file_path)}: {len(content)} символов') - except Exception as e: - print(f'❌ Ошибка чтения файла {file_path}: {e}') - return False - - first_content = file_contents[0][1] - all_match = True - - for file_path, content in file_contents[1:]: - if content == first_content: - print(f'✅ {os.path.basename(file_path)} совпадает с {os.path.basename(file_paths[0])}') - else: - print(f'❌ {os.path.basename(file_path)} НЕ совпадает с {os.path.basename(file_paths[0])}') - all_match = False - - return all_match - - -def verify_files_by_hash(file_paths): - """ - Проверяет совпадение файлов по хеш-суммам - """ - print('\n🔐 Проверка по MD5 хеш-суммам...') - - hashes = {} - for file_path in file_paths: - try: - with open(file_path, 'rb') as f: - file_hash = hashlib.md5() - while chunk := f.read(8192): - file_hash.update(chunk) - hashes[file_path] = file_hash.hexdigest() - print(f'📁 {os.path.basename(file_path)}: {hashes[file_path]}') - except Exception as e: - print(f'❌ Ошибка вычисления хеша для {file_path}: {e}') - return False - - first_hash = list(hashes.values())[0] - all_match = all(h == first_hash for h in hashes.values()) - - if all_match: - print('✅ Все файлы имеют одинаковую хеш-сумму') - else: - print('❌ Файлы имеют разные хеш-суммы') - - return all_match - - -def verify_file_sizes(file_paths): - """ - Проверяет совпадение размеров файлов - """ - print('\n📊 Проверка размеров файлов...') - - sizes = {} - for file_path in file_paths: - try: - size = os.path.getsize(file_path) - sizes[file_path] = size - print(f'📁 {os.path.basename(file_path)}: {size} байт') - except Exception as e: - print(f'❌ Ошибка получения размера для {file_path}: {e}') - return False - - first_size = list(sizes.values())[0] - all_match = all(s == first_size for s in sizes.values()) - - if all_match: - print('✅ Все файлы имеют одинаковый размер') - else: - print('❌ Файлы имеют разные размеры') - - return all_match - - -def find_test_files(directory=None): - """ - Находит тестовые файлы в указанной директории - """ - if directory is None: - directory = Path(__file__).parent - - test_files = list(directory.glob('test*.txt')) - test_files.sort() - - return test_files - - -def main(): - print('=' * 50) - print('🔍 ПРОВЕРКА ТЕСТОВЫХ ФАЙЛОВ') - print('=' * 50) - - test_files = find_test_files() - - if not test_files: - print('❌ Не найдены тестовые файлы (test*.txt) в текущей директории') - print('Доступные файлы:') - for file in Path(__file__).parent.iterdir(): - if file.is_file(): - print(f' - {file.name}') - sys.exit(1) - - print(f'📁 Найдено файлов: {len(test_files)}') - for file in test_files: - print(f' • {file.name}') - - file_paths = [str(f) for f in test_files] - - size_ok = verify_file_sizes(file_paths) - hash_ok = verify_files_by_hash(file_paths) - content_ok = verify_files_by_content(file_paths) - - print('\n' + '=' * 50) - print('📊 ИТОГИ ПРОВЕРКИ:') - print('=' * 50) - print(f'✅ Размеры файлов: {"СОВПАДАЮТ" if size_ok else "НЕ СОВПАДАЮТ"}') - print(f'✅ Хеш-суммы: {"СОВПАДАЮТ" if hash_ok else "НЕ СОВПАДАЮТ"}') - print(f'✅ Содержимое: {"СОВПАДАЕТ" if content_ok else "НЕ СОВПАДАЕТ"}') - - if size_ok and hash_ok and content_ok: - print('\n🎉 ВСЕ ФАЙЛЫ ИДЕНТИЧНЫ! Передача прошла успешно!') - sys.exit(0) - else: - print('\n💥 Обнаружены различия в файлах!') - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/uv.lock b/uv.lock index 9eb7840..046a220 100644 --- a/uv.lock +++ b/uv.lock @@ -1,71 +1,362 @@ version = 1 revision = 2 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[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, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +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/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] [[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -89,45 +380,1377 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[[package]] +name = "def-form" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "libcst" }, + { name = "rich" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/7b/07f78aa01e83dffed318dbaf8440cd698c436011b60e9a042395986a305a/def_form-0.2.0.tar.gz", hash = "sha256:761e8c199115e7d310c22b576bd6cb76641ade8135492b9416851f1d7b965e98", size = 283653, upload-time = "2026-02-04T10:14:14.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/2b/1eb14b1045f9988203b37daf7db55a3bb73b5adebd08d0da4415d2014780/def_form-0.2.0-py3-none-any.whl", hash = "sha256:5463b512dc04e7eb820503fc346e028f62adf6e65f25d462990ce065c2a22a4e", size = 23389, upload-time = "2026-02-04T10:14:12.672Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version >= '3.14'" }, + { name = "pyyaml-ft", marker = "python_full_version < '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "netvault" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "bcrypt" }, - { name = "click" }, + { name = "aioboto3" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "email-validator" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "minio" }, + { name = "psycopg2-binary" }, + { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic-settings" }, + { name = "python-jose" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "structlog" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "def-form" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "bcrypt", specifier = ">=5.0.0" }, - { name = "click", specifier = ">=8.3.1" }, - { name = "ruff", specifier = ">=0.14.7" }, + { name = "aioboto3", specifier = ">=15.5.0" }, + { name = "alembic", specifier = ">=1.18.4" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "email-validator", specifier = ">=2.3.0" }, + { name = "fastapi", specifier = ">=0.128.7" }, + { name = "greenlet", specifier = ">=3.3.1" }, + { name = "minio", specifier = ">=7.2.20" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, + { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "python-jose", specifier = ">=3.5.0" }, + { name = "redis", specifier = ">=7.2.0" }, + { name = "sqlalchemy", specifier = ">=2.0.46" }, + { name = "structlog", specifier = ">=25.5.0" }, + { name = "uvicorn", specifier = ">=0.40.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "def-form", specifier = ">=0.2.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.15.0" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pwdlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/41/a7c0d8a003c36ce3828ae3ed0391fe6a15aad65f082dbd6bec817ea95c0b/pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc", size = 215810, upload-time = "2025-10-25T12:44:24.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0c/9086a357d02a050fbb3270bf5043ac284dbfb845670e16c9389a41defc9e/pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d", size = 8633, upload-time = "2025-10-25T12:44:23.406Z" }, +] + +[package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + +[[package]] +name = "redis" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] [[package]] name = "ruff" -version = "0.14.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" }, - { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" }, - { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" }, - { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" }, - { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" }, - { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" }, - { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" }, - { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" }, - { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" }, - { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" }, - { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" }, +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.46" +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/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, + { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ]