commit f7309c4b4a09ba498eaf6dd1aa0cede3217e7d0c Author: Noloquideus Date: Wed Jun 3 13:52:45 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..509ff51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +generate_password_hash.py +# C extensions +*.so +*.pyd +*.dll + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache/ +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ + +# Type checkers / linters +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +.ruff_cache/ + +# Jupyter Notebook +.ipynb_checkpoints/ + +# Environments +.env +.env.* +.venv/ +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ + +# Poetry +poetry.lock + +# Pipenv +Pipfile.lock + +# Hatch +.hatch/ + +# pyenv +.python-version + +# Logs +*.log +logs/ + +# Local databases +*.sqlite3 +*.db + +# Secrets / credentials +secrets.json +credentials.json +*.pem +*.key +*.crt + +# OS generated files +.DS_Store +Thumbs.db +Desktop.ini + +# PyCharm / IntelliJ IDEA +.idea/ +*.iml +out/ + +# VS Code (optional) +.vscode/ + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# Sphinx docs +docs/_build/ + +# mkdocs +site/ + +# celery +celerybeat-schedule +celerybeat.pid + +# mypy compiled cache +.mypy_cache/ + +# pyinstaller +*.manifest +*.spec + +# pytest debug +pytestdebug.log + +# Local config overrides +config.local.py +settings.local.py + +# Vault / local dev secrets +.env.vault +vault.token + +.env +.dockerignore +/sql \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ec2245f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS builder + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY src ./src + + +FROM ghcr.io/astral-sh/uv:python3.12-bookworm AS runtime + +WORKDIR /app + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/src /app/src + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/app + +EXPOSE 8000 + +CMD ["sh", "-c", "python -m granian --interface asgi ${APP_MODULE:-src.main:app} --host ${APP_HOST:-0.0.0.0} --port ${APP_PORT:-8000} --workers ${APP_WORKERS:-2} --loop uvloop"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f51b8fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + admin: + container_name: admin-service + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + environment: + PYTHONUNBUFFERED: "1" + APP_MODULE: "src.main:app" + APP_HOST: "0.0.0.0" + APP_PORT: "8000" + APP_WORKERS: "2" + env_file: + - .env + restart: no diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..280fe73 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "admin-service" +version = "0.1.0" +description = "Admin service for legal entities and B2B operations" +requires-python = "==3.12.*" +dependencies = [ + "acryl-datahub>=1.5.0.19", + "acryl-sqlglot>=25.25.2.dev9", + "aiobotocore>=2.21.0", + "apscheduler==3.11.2", + "asyncpg==0.31.0", + "bcrypt==5.0.0", + "bip-utils>=2.9.3", + "cryptography>=44.0.0", + "dotenv==0.9.9", + "fastapi==0.128.7", + "granian==2.6.1", + "hvac==2.4.0", + "orjson==3.11.7", + "pydantic-settings==2.12.0", + "python-jose==3.5.0", + "python-multipart>=0.0.20", + "python-ulid==3.1.0", + "redis==7.2.0", + "sqlalchemy==2.0.46", + "uvloop==0.22.1; platform_system != 'Windows'", +] diff --git a/src/application/abstractions/__init__.py b/src/application/abstractions/__init__.py new file mode 100644 index 0000000..76a0e8d --- /dev/null +++ b/src/application/abstractions/__init__.py @@ -0,0 +1 @@ +from src.application.abstractions.i_unit_of_work import IUnitOfWork \ No newline at end of file diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py new file mode 100644 index 0000000..0860fcf --- /dev/null +++ b/src/application/abstractions/i_unit_of_work.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from src.application.abstractions.repositories import ( + IAdminSessionRepository, + IAdminUserRepository, + ILegalEntityRepository, + IOrganizationDocumentRepository, + IOrganizationWalletRepository, + IPurchaseRequestRepository, + IUserRepository, +) + + +@runtime_checkable +class IUnitOfWork(Protocol): + async def __aenter__(self) -> 'IUnitOfWork': ... + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ... + + async def commit(self) -> None: ... + async def rollback(self) -> None: ... + + @property + def user_repository(self) -> IUserRepository: ... + + @property + def admin_user_repository(self) -> IAdminUserRepository: ... + + @property + def admin_session_repository(self) -> IAdminSessionRepository: ... + + @property + def legal_entity_repository(self) -> ILegalEntityRepository: ... + + @property + def organization_wallet_repository(self) -> IOrganizationWalletRepository: ... + + @property + def organization_document_repository(self) -> IOrganizationDocumentRepository: ... + + @property + def purchase_request_repository(self) -> IPurchaseRequestRepository: ... diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py new file mode 100644 index 0000000..743d4ea --- /dev/null +++ b/src/application/abstractions/repositories/__init__.py @@ -0,0 +1,7 @@ +from src.application.abstractions.repositories.i_user_repository import IUserRepository +from src.application.abstractions.repositories.i_admin_user_repository import IAdminUserRepository +from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository +from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository +from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository +from src.application.abstractions.repositories.i_organization_document_repository import IOrganizationDocumentRepository +from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository diff --git a/src/application/abstractions/repositories/i_admin_session_repository.py b/src/application/abstractions/repositories/i_admin_session_repository.py new file mode 100644 index 0000000..14452f4 --- /dev/null +++ b/src/application/abstractions/repositories/i_admin_session_repository.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Optional + +from src.application.domain.entities.admin_session import AdminSessionEntity + + +class IAdminSessionRepository(ABC): + @abstractmethod + async def get_by_sid(self, sid: str) -> Optional[AdminSessionEntity]: + raise NotImplementedError + + @abstractmethod + async def upsert_by_device( + self, + *, + admin_user_id: str, + device_id: str, + sid: str, + refresh_jti_hash: str, + refresh_expires_at: datetime, + user_agent: str | None, + ip: str | None, + now: datetime, + ) -> AdminSessionEntity: + raise NotImplementedError + + @abstractmethod + async def revoke_by_sid(self, sid: str, now: datetime) -> None: + raise NotImplementedError + + @abstractmethod + async def rotate_refresh_if_match( + self, + *, + sid: str, + old_jti_hash: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> bool: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_admin_user_repository.py b/src/application/abstractions/repositories/i_admin_user_repository.py new file mode 100644 index 0000000..8c914bc --- /dev/null +++ b/src/application/abstractions/repositories/i_admin_user_repository.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.admin_user import AdminUserEntity + + +class IAdminUserRepository(ABC): + @abstractmethod + async def get_by_email(self, email: str) -> AdminUserEntity: + raise NotImplementedError + + @abstractmethod + async def get_by_id(self, admin_user_id: str) -> AdminUserEntity: + raise NotImplementedError + + @abstractmethod + async def update_last_login(self, admin_user_id: str, *, last_login_at) -> None: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_legal_entity_repository.py b/src/application/abstractions/repositories/i_legal_entity_repository.py new file mode 100644 index 0000000..0d193e8 --- /dev/null +++ b/src/application/abstractions/repositories/i_legal_entity_repository.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod +from typing import Any + +from src.application.domain.entities.organization import LegalEntityEntity + + +class ILegalEntityRepository(ABC): + @abstractmethod + async def create( + self, + *, + user_id: str, + name: str, + short_name: str | None, + inn: str, + ogrn: str | None, + kpp: str | None, + legal_address: str | None, + actual_address: str | None, + bank_details: dict[str, Any] | None, + contact_person: str | None, + contact_phone: str | None, + status: str, + kyc_verified: bool, + kyc_verified_at, + created_by: str | None, + ) -> LegalEntityEntity: + raise NotImplementedError + + @abstractmethod + async def get_by_id(self, organization_id: str) -> LegalEntityEntity: + raise NotImplementedError + + @abstractmethod + async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: + raise NotImplementedError + + @abstractmethod + async def update( + self, + organization_id: str, + *, + values: dict[str, Any], + ) -> LegalEntityEntity: + raise NotImplementedError + + @abstractmethod + async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None: + raise NotImplementedError + + @abstractmethod + async def count_all(self) -> int: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_organization_document_repository.py b/src/application/abstractions/repositories/i_organization_document_repository.py new file mode 100644 index 0000000..5003e24 --- /dev/null +++ b/src/application/abstractions/repositories/i_organization_document_repository.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.organization import OrganizationDocumentEntity + + +class IOrganizationDocumentRepository(ABC): + @abstractmethod + async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity: + raise NotImplementedError + + @abstractmethod + async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity: + raise NotImplementedError + + @abstractmethod + async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_organization_wallet_repository.py b/src/application/abstractions/repositories/i_organization_wallet_repository.py new file mode 100644 index 0000000..d36b4f5 --- /dev/null +++ b/src/application/abstractions/repositories/i_organization_wallet_repository.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from src.application.domain.entities.organization import OrganizationWalletEntity + + +class IOrganizationWalletRepository(ABC): + @abstractmethod + async def create_many(self, wallets: list[OrganizationWalletEntity]) -> list[OrganizationWalletEntity]: + raise NotImplementedError + + @abstractmethod + async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]: + raise NotImplementedError + + @abstractmethod + async def exists_for_organization(self, organization_id: str) -> bool: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_purchase_request_repository.py b/src/application/abstractions/repositories/i_purchase_request_repository.py new file mode 100644 index 0000000..a90c7f7 --- /dev/null +++ b/src/application/abstractions/repositories/i_purchase_request_repository.py @@ -0,0 +1,29 @@ +from abc import ABC, abstractmethod +from typing import Any + +from src.application.domain.entities.organization import PurchaseRequestEntity + + +class IPurchaseRequestRepository(ABC): + @abstractmethod + async def get_by_id(self, request_id: str) -> PurchaseRequestEntity: + raise NotImplementedError + + @abstractmethod + async def list_all( + self, + *, + status: str | None, + organization_id: str | None, + limit: int, + offset: int, + ) -> list[PurchaseRequestEntity]: + raise NotImplementedError + + @abstractmethod + async def update(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity: + raise NotImplementedError + + @abstractmethod + async def count_all(self, *, status: str | None, organization_id: str | None) -> int: + raise NotImplementedError diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py new file mode 100644 index 0000000..c9c8d79 --- /dev/null +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from datetime import datetime + +from src.application.domain.entities import UserEntity + + +class IUserRepository(ABC): + @abstractmethod + async def create_legal_entity_user( + self, + *, + email: str, + password_hash: str, + provisioned_by: str, + provisioned_at: datetime, + kyc_verified: bool, + kyc_verified_at: datetime, + ) -> UserEntity: + raise NotImplementedError + + @abstractmethod + async def get_user_by_email(self, email: str) -> UserEntity: + raise NotImplementedError + + @abstractmethod + async def exists_by_email(self, email: str) -> bool: + raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py new file mode 100644 index 0000000..51d98cf --- /dev/null +++ b/src/application/commands/__init__.py @@ -0,0 +1,37 @@ +from src.application.commands.admin_login import AdminLoginCommand +from src.application.commands.get_admin_me import GetAdminMeCommand +from src.application.commands.create_organization import CreateOrganizationCommand +from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand +from src.application.commands.upload_organization_document import UploadOrganizationDocumentCommand +from src.application.commands.organization_commands import ( + ListOrganizationsCommand, + GetOrganizationCommand, + UpdateOrganizationCommand, +) +from src.application.commands.organization_document_commands import ( + ListOrganizationDocumentsCommand, + GetOrganizationDocumentCommand, +) +from src.application.commands.purchase_request_commands import ( + ListPurchaseRequestsCommand, + GetPurchaseRequestCommand, + UpdatePurchaseRequestStatusCommand, + SetPurchaseRequestQuoteCommand, +) + +__all__ = [ + 'AdminLoginCommand', + 'GetAdminMeCommand', + 'CreateOrganizationCommand', + 'CreateOrganizationWalletsCommand', + 'UploadOrganizationDocumentCommand', + 'ListOrganizationsCommand', + 'GetOrganizationCommand', + 'UpdateOrganizationCommand', + 'ListPurchaseRequestsCommand', + 'GetPurchaseRequestCommand', + 'UpdatePurchaseRequestStatusCommand', + 'SetPurchaseRequestQuoteCommand', + 'ListOrganizationDocumentsCommand', + 'GetOrganizationDocumentCommand', +] diff --git a/src/application/commands/admin_jwt_refresh.py b/src/application/commands/admin_jwt_refresh.py new file mode 100644 index 0000000..8cf5db8 --- /dev/null +++ b/src/application/commands/admin_jwt_refresh.py @@ -0,0 +1,112 @@ +import asyncio +from datetime import datetime, timezone, timedelta + +from ulid import ULID + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, IJwtService, ILogger, ICache +from src.application.domain.dto import RefreshTokenPayload +from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException +from src.infrastructure.config import settings +from src.infrastructure.database.decorators import transactional + + +class AdminJwtRefreshCommand: + _LOCK_PREFIX = 'admin:jwt:refresh:lock:' + _LOCK_TTL_SECONDS = 15 + _LOCK_WAIT_ATTEMPTS = 40 + _LOCK_WAIT_INTERVAL_SECONDS = 0.05 + + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + jwt_service: IJwtService, + cache: ICache, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._jwt_service = jwt_service + self._cache = cache + self._logger = logger + + @transactional + async def __call__(self, *, refresh_token: str, ip: str | None, user_agent: str | None) -> tuple[str, str]: + now = datetime.now(timezone.utc) + payload: RefreshTokenPayload = await self._jwt_service.decode_refresh_token(refresh_token) + + sid = payload.sid + admin_user_id = payload.sub + jti = payload.jti + + lock_key = f'{self._LOCK_PREFIX}{sid}' + locked = await self._cache.set_nx(lock_key, '1', self._LOCK_TTL_SECONDS) + if not locked: + for _ in range(self._LOCK_WAIT_ATTEMPTS): + await asyncio.sleep(self._LOCK_WAIT_INTERVAL_SECONDS) + if await self._cache.get(lock_key) is None: + raise RefreshConcurrentException() + raise ApplicationException(status_code=429, message='Refresh in progress') + + try: + return await self._refresh_locked( + sid=sid, + admin_user_id=admin_user_id, + jti=jti, + now=now, + ip=ip, + user_agent=user_agent, + ) + finally: + await self._cache.delete(lock_key) + + async def _refresh_locked( + self, + *, + sid: str, + admin_user_id: str, + jti: str, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> tuple[str, str]: + sess = await self._unit_of_work.admin_session_repository.get_by_sid(sid) + if sess is None: + raise ApplicationException(status_code=401, message='Session not found') + if sess.revoked_at is not None: + raise ApplicationException(status_code=401, message='Session revoked') + if sess.refresh_expires_at is None or sess.refresh_expires_at <= now: + raise ApplicationException(status_code=401, message='Session expired') + if str(sess.admin_user_id) != str(admin_user_id): + raise ApplicationException(status_code=401, message='Invalid session subject') + + ok = await self._hash_service.verify(plain_value=jti, hashed_value=sess.refresh_jti_hash) + if not ok: + await self._unit_of_work.admin_session_repository.revoke_by_sid(sid=sid, now=now) + raise ApplicationException(status_code=401, message='Refresh token reuse detected') + + admin = await self._unit_of_work.admin_user_repository.get_by_id(admin_user_id) + new_jti = str(ULID()) + new_jti_hash = await self._hash_service.hash(value=new_jti) + new_refresh_expires_at = now + timedelta(seconds=int(settings.JWT_REFRESH_TTL_SECONDS)) + + rotated = await self._unit_of_work.admin_session_repository.rotate_refresh_if_match( + sid=sid, + old_jti_hash=sess.refresh_jti_hash, + new_jti_hash=new_jti_hash, + new_refresh_expires_at=new_refresh_expires_at, + now=now, + ip=ip, + user_agent=user_agent, + ) + if not rotated: + raise RefreshConcurrentException() + + access = await self._jwt_service.create_access_token( + user_id=admin_user_id, sid=sid, role=admin.role + ) + refresh = await self._jwt_service.create_refresh_token( + user_id=admin_user_id, sid=sid, refresh_jti=new_jti + ) + return access, refresh diff --git a/src/application/commands/admin_login.py b/src/application/commands/admin_login.py new file mode 100644 index 0000000..17ff2dc --- /dev/null +++ b/src/application/commands/admin_login.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, IJwtService, ILogger +from src.application.domain.dto.admin_auth import AdminLoginDto +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class AdminLoginCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + hash_service: IHashService, + jwt_service: IJwtService, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._jwt_service = jwt_service + self._logger = logger + + @transactional + async def __call__(self, *, email: str, password: str) -> AdminLoginDto: + email = (email or '').strip().lower() + admin = await self._unit_of_work.admin_user_repository.get_by_email(email) + + if not admin.is_active: + raise ApplicationException(status_code=403, message='Admin account is inactive') + + ok = await self._hash_service.verify(plain_value=password, hashed_value=admin.password_hash) + if not ok: + self._logger.warning(f'Admin login failed for {email}') + raise ApplicationException(status_code=401, message='Invalid credentials') + + now = datetime.now(timezone.utc) + await self._unit_of_work.admin_user_repository.update_last_login(admin.id, last_login_at=now) + + access_token = await self._jwt_service.create_access_token( + user_id=admin.id, + role=admin.role, + ) + + self._logger.info(f'Admin logged in admin_user_id={admin.id}') + + return AdminLoginDto( + id=admin.id, + email=admin.email, + first_name=admin.first_name, + last_name=admin.last_name, + role=admin.role, + access_token=access_token, + last_login_at=now, + ) diff --git a/src/application/commands/admin_logout.py b/src/application/commands/admin_logout.py new file mode 100644 index 0000000..583435f --- /dev/null +++ b/src/application/commands/admin_logout.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IJwtService, ILogger +from src.application.domain.dto import RefreshTokenPayload +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class AdminLogoutCommand: + def __init__(self, unit_of_work: IUnitOfWork, jwt_service: IJwtService, logger: ILogger): + self._unit_of_work = unit_of_work + self._jwt_service = jwt_service + self._logger = logger + + @transactional + async def __call__(self, *, refresh_token: str | None) -> None: + if not refresh_token: + return + try: + payload: RefreshTokenPayload = await self._jwt_service.decode_refresh_token(refresh_token) + except ApplicationException: + self._logger.debug('Logout: refresh token invalid/expired, skipping revoke') + return + + now = datetime.now(timezone.utc) + await self._unit_of_work.admin_session_repository.revoke_by_sid(sid=payload.sid, now=now) + self._logger.info(f'Logout: session revoked (sid={payload.sid}, admin_user_id={payload.sub})') diff --git a/src/application/commands/create_organization.py b/src/application/commands/create_organization.py new file mode 100644 index 0000000..a7783a9 --- /dev/null +++ b/src/application/commands/create_organization.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import IHashService, ILogger +from src.application.domain.entities.organization import LegalEntityEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class CreateOrganizationCommand: + def __init__(self, unit_of_work: IUnitOfWork, hash_service: IHashService, logger: ILogger): + self._unit_of_work = unit_of_work + self._hash_service = hash_service + self._logger = logger + + @transactional + async def __call__( + self, + *, + admin_user_id: str, + email: str, + password: str, + name: str, + short_name: str | None, + inn: str, + ogrn: str | None, + kpp: str | None, + legal_address: str | None, + actual_address: str | None, + bank_details: dict[str, Any] | None, + contact_person: str | None, + contact_phone: str | None, + status: str = 'active', + ) -> LegalEntityEntity: + email = (email or '').strip().lower() + if await self._unit_of_work.user_repository.exists_by_email(email): + raise ApplicationException(status_code=409, message='User with this email already exists') + + now = datetime.now(timezone.utc) + password_hash = await self._hash_service.hash(value=password) + user = await self._unit_of_work.user_repository.create_legal_entity_user( + email=email, + password_hash=password_hash, + provisioned_by=admin_user_id, + provisioned_at=now, + kyc_verified=True, + kyc_verified_at=now, + ) + + org = await self._unit_of_work.legal_entity_repository.create( + user_id=user.id, + name=name, + short_name=short_name, + inn=inn, + ogrn=ogrn, + kpp=kpp, + legal_address=legal_address, + actual_address=actual_address, + bank_details=bank_details, + contact_person=contact_person, + contact_phone=contact_phone, + status=status, + kyc_verified=True, + kyc_verified_at=now, + created_by=admin_user_id, + ) + self._logger.info(f'Organization created id={org.id} user_id={user.id}') + return org diff --git a/src/application/commands/create_organization_wallets.py b/src/application/commands/create_organization_wallets.py new file mode 100644 index 0000000..356c998 --- /dev/null +++ b/src/application/commands/create_organization_wallets.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from ulid import ULID + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationWalletEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.crypto.wallet_crypto import ( + ALL_CHAINS, + derive_all_addresses, + encrypt_mnemonic, + generate_mnemonic, + is_crypto_ready, +) +from src.infrastructure.database.decorators import transactional + + +class CreateOrganizationWalletsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, *, organization_id: str) -> list[OrganizationWalletEntity]: + if not is_crypto_ready(): + raise ApplicationException(status_code=503, message='Crypto service not ready') + + org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + if org.encrypted_mnemonic: + raise ApplicationException(status_code=409, message='Wallets already created for organization') + + if await self._unit_of_work.organization_wallet_repository.exists_for_organization(organization_id): + raise ApplicationException(status_code=409, message='Wallets already exist for organization') + + mnemonic = generate_mnemonic() + derived = derive_all_addresses(mnemonic) + blob = encrypt_mnemonic(mnemonic) + mnemonic = '' + + await self._unit_of_work.legal_entity_repository.set_encrypted_mnemonic(organization_id, blob) + + wallets = [ + OrganizationWalletEntity( + id=str(ULID()), + organization_id=organization_id, + chain=item.chain, + address=item.address, + derivation_path=item.derivation_path, + ) + for item in derived + if item.chain in ALL_CHAINS + ] + saved = await self._unit_of_work.organization_wallet_repository.create_many(wallets) + self._logger.info(f'Wallets created for organization_id={organization_id} chains={len(saved)}') + return saved diff --git a/src/application/commands/get_admin_me.py b/src/application/commands/get_admin_me.py new file mode 100644 index 0000000..ccd027a --- /dev/null +++ b/src/application/commands/get_admin_me.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.admin_user import AdminUserEntity +from src.infrastructure.database.decorators import transactional + + +class GetAdminMeCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, admin_user_id: str) -> AdminUserEntity: + return await self._unit_of_work.admin_user_repository.get_by_id(admin_user_id) diff --git a/src/application/commands/organization_commands.py b/src/application/commands/organization_commands.py new file mode 100644 index 0000000..a7e3d81 --- /dev/null +++ b/src/application/commands/organization_commands.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import LegalEntityEntity +from src.infrastructure.database.decorators import transactional + + +class ListOrganizationsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]: + items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset) + total = await self._unit_of_work.legal_entity_repository.count_all() + return items, total + + +class GetOrganizationCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> LegalEntityEntity: + return await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + + +class UpdateOrganizationCommand: + ALLOWED_FIELDS = frozenset({ + 'name', 'short_name', 'ogrn', 'kpp', 'legal_address', 'actual_address', + 'bank_details', 'contact_person', 'contact_phone', 'status', + }) + + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity: + filtered = {k: v for k, v in values.items() if k in self.ALLOWED_FIELDS and v is not None} + return await self._unit_of_work.legal_entity_repository.update(organization_id, values=filtered) diff --git a/src/application/commands/organization_document_commands.py b/src/application/commands/organization_document_commands.py new file mode 100644 index 0000000..c251616 --- /dev/null +++ b/src/application/commands/organization_document_commands.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationDocumentEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +class ListOrganizationDocumentsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]: + await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + return await self._unit_of_work.organization_document_repository.list_by_organization(organization_id) + + +class GetOrganizationDocumentCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity: + doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id) + if doc.organization_id != organization_id: + raise ApplicationException(status_code=404, message='Document not found') + return doc diff --git a/src/application/commands/purchase_request_commands.py b/src/application/commands/purchase_request_commands.py new file mode 100644 index 0000000..8f3a2ad --- /dev/null +++ b/src/application/commands/purchase_request_commands.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import PurchaseRequestEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional + + +VALID_STATUSES = frozenset({ + 'submitted', 'in_review', 'quote_sent', 'payment_pending', + 'payment_received', 'usdt_sent', 'completed', 'rejected', 'cancelled', +}) + + +class ListPurchaseRequestsCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__( + self, + *, + status: str | None = None, + organization_id: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[PurchaseRequestEntity], int]: + items = await self._unit_of_work.purchase_request_repository.list_all( + status=status, + organization_id=organization_id, + limit=limit, + offset=offset, + ) + total = await self._unit_of_work.purchase_request_repository.count_all( + status=status, + organization_id=organization_id, + ) + return items, total + + +class GetPurchaseRequestCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__(self, request_id: str) -> PurchaseRequestEntity: + return await self._unit_of_work.purchase_request_repository.get_by_id(request_id) + + +class UpdatePurchaseRequestStatusCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__( + self, + request_id: str, + *, + status: str, + admin_comment: str | None = None, + assigned_to: str | None = None, + tx_hash: str | None = None, + ) -> PurchaseRequestEntity: + if status not in VALID_STATUSES: + raise ApplicationException(status_code=400, message='Invalid status') + + values: dict[str, Any] = {'status': status} + if admin_comment is not None: + values['admin_comment'] = admin_comment + if assigned_to is not None: + values['assigned_to'] = assigned_to + if tx_hash is not None: + values['tx_hash'] = tx_hash + if status == 'completed': + values['completed_at'] = datetime.now(timezone.utc) + + return await self._unit_of_work.purchase_request_repository.update(request_id, values=values) + + +class SetPurchaseRequestQuoteCommand: + def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): + self._unit_of_work = unit_of_work + self._logger = logger + + @transactional + async def __call__( + self, + request_id: str, + *, + rub_amount: Decimal, + exchange_rate: Decimal, + service_fee_percent: Decimal | None = None, + admin_comment: str | None = None, + ) -> PurchaseRequestEntity: + values: dict[str, Any] = { + 'rub_amount': rub_amount, + 'exchange_rate': exchange_rate, + 'status': 'quote_sent', + } + if service_fee_percent is not None: + values['service_fee_percent'] = service_fee_percent + if admin_comment is not None: + values['admin_comment'] = admin_comment + return await self._unit_of_work.purchase_request_repository.update(request_id, values=values) diff --git a/src/application/commands/upload_organization_document.py b/src/application/commands/upload_organization_document.py new file mode 100644 index 0000000..694b31a --- /dev/null +++ b/src/application/commands/upload_organization_document.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from ulid import ULID + +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationDocumentEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.decorators import transactional +from src.infrastructure.storage.s3_documents_service import S3DocumentsService + + +ALLOWED_DOCUMENT_TYPES = frozenset({ + 'charter', 'inn_certificate', 'ogrn_certificate', + 'bank_details', 'kyc_representative', 'power_of_attorney', 'other', +}) + + +class UploadOrganizationDocumentCommand: + def __init__( + self, + unit_of_work: IUnitOfWork, + s3_service: S3DocumentsService, + logger: ILogger, + ): + self._unit_of_work = unit_of_work + self._s3 = s3_service + self._logger = logger + + @transactional + async def __call__( + self, + *, + organization_id: str, + admin_user_id: str, + document_type: str, + file_name: str, + content_type: str, + body: bytes, + ) -> OrganizationDocumentEntity: + if document_type not in ALLOWED_DOCUMENT_TYPES: + raise ApplicationException(status_code=400, message='Invalid document type') + + await self._unit_of_work.legal_entity_repository.get_by_id(organization_id) + + document_id = str(ULID()) + s3_key = self._s3.build_object_key(organization_id, document_id, file_name) + await self._s3.upload_bytes(key=s3_key, body=body, content_type=content_type) + + entity = OrganizationDocumentEntity( + id=document_id, + organization_id=organization_id, + document_type=document_type, + file_name=file_name, + s3_key=s3_key, + content_type=content_type, + file_size_bytes=len(body), + uploaded_by=admin_user_id, + ) + saved = await self._unit_of_work.organization_document_repository.create(entity) + self._logger.info(f'Document uploaded id={saved.id} org={organization_id}') + return saved diff --git a/src/application/contracts/__init__.py b/src/application/contracts/__init__.py new file mode 100644 index 0000000..0fd1d6d --- /dev/null +++ b/src/application/contracts/__init__.py @@ -0,0 +1,7 @@ +from src.application.contracts.i_hash_service import IHashService +from src.application.contracts.i_logger import ILogger +from src.application.contracts.i_user_service import IUserService +from src.application.contracts.i_jwt_service import IJwtService +from src.application.contracts.i_csrf_service import ICsrfService +from src.application.contracts.i_cache import ICache +from src.application.contracts.i_queue_messanger import IQueueMessanger \ No newline at end of file diff --git a/src/application/contracts/i_cache.py b/src/application/contracts/i_cache.py new file mode 100644 index 0000000..cb6acf9 --- /dev/null +++ b/src/application/contracts/i_cache.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + + +class ICache(ABC): + + @abstractmethod + async def set(self, key: str, value: str, ttl: int) -> bool: + raise NotImplementedError + + @abstractmethod + async def set_nx(self, key: str, value: str, ttl: int) -> bool: + raise NotImplementedError + + @abstractmethod + async def get(self, key: str) -> str | None: + raise NotImplementedError + + @abstractmethod + async def delete(self, key: str) -> bool: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_csrf_service.py b/src/application/contracts/i_csrf_service.py new file mode 100644 index 0000000..a493d60 --- /dev/null +++ b/src/application/contracts/i_csrf_service.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Any, Optional, Mapping + + +class ICsrfService(ABC): + @abstractmethod + def issue(self, subject: Optional[str] = None) -> str: + raise NotImplementedError + + @abstractmethod + def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: + raise NotImplementedError + + @abstractmethod + def verify_pair( + self, + cookie_token: Optional[str], + header_token: Optional[str], + expected_subject: Optional[str] = None, + ) -> None: + raise NotImplementedError diff --git a/src/application/contracts/i_hash_service.py b/src/application/contracts/i_hash_service.py new file mode 100644 index 0000000..438090f --- /dev/null +++ b/src/application/contracts/i_hash_service.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + + +class IHashService(ABC): + + @abstractmethod + async def hash(self, value: str) -> str: + raise NotImplementedError + + @abstractmethod + async def verify(self, hashed_value: str, plain_value: str) -> bool: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_jwt_service.py b/src/application/contracts/i_jwt_service.py new file mode 100644 index 0000000..82359f2 --- /dev/null +++ b/src/application/contracts/i_jwt_service.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from src.application.domain.dto import AccessTokenPayload + + +class IJwtService(ABC): + @abstractmethod + async def create_access_token(self, user_id: str, *, role: str) -> str: + raise NotImplementedError + + @abstractmethod + async def decode_access_token(self, token: str) -> AccessTokenPayload: + raise NotImplementedError diff --git a/src/application/contracts/i_logger.py b/src/application/contracts/i_logger.py new file mode 100644 index 0000000..1ac5797 --- /dev/null +++ b/src/application/contracts/i_logger.py @@ -0,0 +1,68 @@ +from typing import Protocol, Optional, Callable +from src.application.domain.enums.log_format import LogFormat +from src.application.domain.enums.log_level import LogLevel + + +class ILogger(Protocol): + """Interface for synchronous logger with ContextVar support for trace_id.""" + + log_format: LogFormat + min_level: LogLevel + id_generator: Optional[Callable[[], str]] + instance_id: str + + def set_format(self, log_format: LogFormat) -> None: + """Set log format using LogFormat enum""" + ... + + def set_min_level(self, level: LogLevel) -> None: + """Set minimum log level""" + ... + + def new_trace_id(self) -> str: + """Create and set new trace_id in context""" + ... + + def set_trace_id(self, trace_id: str) -> None: + """Set existing trace_id in context""" + ... + + def get_trace_id(self) -> str: + """Get current trace_id from context""" + ... + + def clear_trace_id(self) -> None: + """Clear the trace_id in the context""" + ... + + def set_instance_id(self, instance_id: str) -> None: + """Set service instance id (ULID recommended)""" + ... + + def get_instance_id(self) -> str: + """Get current service instance id""" + ... + + def debug(self, message: str) -> None: + """Log debug message""" + ... + + def info(self, message: str) -> None: + """Log info message""" + ... + + def warning(self, message: str) -> None: + """Log warning message""" + ... + + def error(self, message: str) -> None: + """Log error message""" + ... + + def critical(self, message: str) -> None: + """Log critical message""" + ... + + def exception(self, message: str) -> None: + """Log exception with traceback""" + ... \ No newline at end of file diff --git a/src/application/contracts/i_queue_messanger.py b/src/application/contracts/i_queue_messanger.py new file mode 100644 index 0000000..ab7be50 --- /dev/null +++ b/src/application/contracts/i_queue_messanger.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Mapping, Any + + +class IQueueMessanger(ABC): + + @abstractmethod + async def connect(self) -> None: + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + raise NotImplementedError + + @abstractmethod + async def publish_to_queue( + self, + queue: str, + message: Any, + *, + persist: bool = True, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + raise NotImplementedError + + @abstractmethod + async def publish( + self, + message: Any, + *, + exchange: str, + routing_key: str, + persist: bool = True, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/contracts/i_user_service.py b/src/application/contracts/i_user_service.py new file mode 100644 index 0000000..7f59f6d --- /dev/null +++ b/src/application/contracts/i_user_service.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from src.application.domain.dto import UserCreatedDto, UserLoginDto + + +class IUserService(ABC): + + @abstractmethod + async def registration(self, email: str, password: str) -> UserCreatedDto: + raise NotImplementedError + + + @abstractmethod + async def login(self, email: str, password: str) -> UserLoginDto: + raise NotImplementedError \ No newline at end of file diff --git a/src/application/domain/dto/__init__.py b/src/application/domain/dto/__init__.py new file mode 100644 index 0000000..d6eb03b --- /dev/null +++ b/src/application/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from src.application.domain.dto.admin_auth import AdminLoginDto +from src.application.domain.dto.token import AccessTokenPayload, AdminAuthContext +from src.application.domain.dto.keys import JwtKeySet, JwtKeyPair diff --git a/src/application/domain/dto/admin_auth.py b/src/application/domain/dto/admin_auth.py new file mode 100644 index 0000000..cc2eed2 --- /dev/null +++ b/src/application/domain/dto/admin_auth.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class AdminLoginDto: + id: str + email: str + first_name: str | None + last_name: str | None + role: str + access_token: str + last_login_at: datetime | None = None diff --git a/src/application/domain/dto/keys.py b/src/application/domain/dto/keys.py new file mode 100644 index 0000000..ed55d85 --- /dev/null +++ b/src/application/domain/dto/keys.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Optional, Dict + + +@dataclass(frozen=True) +class JwtKeyPair: + kid: str + private_key_pem: str + public_key_pem: str + + +@dataclass(frozen=True) +class JwtKeySet: + active: JwtKeyPair + previous: Optional[JwtKeyPair] = None + + def public_keys_by_kid(self) -> Dict[str, str]: + out = {self.active.kid: self.active.public_key_pem} + if self.previous: + out[self.previous.kid] = self.previous.public_key_pem + return out \ No newline at end of file diff --git a/src/application/domain/dto/token.py b/src/application/domain/dto/token.py new file mode 100644 index 0000000..dcdcd94 --- /dev/null +++ b/src/application/domain/dto/token.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class AccessTokenPayload(BaseModel): + sub: str + type: str + role: str | None = None + iat: int + nbf: int + exp: int + iss: str | None = None + aud: str | None = None + + +class AdminAuthContext(BaseModel): + admin_user_id: str + role: str diff --git a/src/application/domain/dto/user.py b/src/application/domain/dto/user.py new file mode 100644 index 0000000..bd33b7a --- /dev/null +++ b/src/application/domain/dto/user.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from datetime import datetime, date + + +@dataclass(slots=True) +class UserCreatedDto: + id: str + email: str + access_token: str + refresh_token: str + + +@dataclass(slots=True) +class UserLoginDto: + id: str | None = None + email: str | None = None + first_name: str | None = None + middle_name: str | None = None + last_name: str | None = None + birth_date: date | None = None + encrypted_mnemonic: str | None = None + phone: str | None = None + passport_data: str | None = None + inn: str | None = None + erc20: str | None = None + avatar_link: str | None = None + kyc_verified: bool | None = None + access_token: str | None = None + refresh_token: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + kyc_verified_at: datetime | None = None + diff --git a/src/application/domain/entities/__init__.py b/src/application/domain/entities/__init__.py new file mode 100644 index 0000000..5b00633 --- /dev/null +++ b/src/application/domain/entities/__init__.py @@ -0,0 +1,5 @@ +from src.application.domain.entities.user import UserEntity +from src.application.domain.entities.admin_user import AdminUserEntity +from src.application.domain.entities.admin_session import AdminSessionEntity + +__all__ = ['UserEntity', 'AdminUserEntity', 'AdminSessionEntity'] diff --git a/src/application/domain/entities/admin_session.py b/src/application/domain/entities/admin_session.py new file mode 100644 index 0000000..d8996f3 --- /dev/null +++ b/src/application/domain/entities/admin_session.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class AdminSessionEntity: + sid: str + admin_user_id: str + device_id: str + revoked_at: datetime | None + last_seen_at: datetime + refresh_jti_hash: str | None + refresh_expires_at: datetime | None + user_agent: str | None = None + first_ip: str | None = None + last_ip: str | None = None diff --git a/src/application/domain/entities/admin_user.py b/src/application/domain/entities/admin_user.py new file mode 100644 index 0000000..2900b6a --- /dev/null +++ b/src/application/domain/entities/admin_user.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class AdminUserEntity: + id: str + email: str + password_hash: str + first_name: str | None + last_name: str | None + role: str + is_active: bool + last_login_at: datetime | None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/application/domain/entities/organization.py b/src/application/domain/entities/organization.py new file mode 100644 index 0000000..4436af8 --- /dev/null +++ b/src/application/domain/entities/organization.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any + + +@dataclass +class LegalEntityEntity: + id: str + user_id: str + name: str + short_name: str | None + inn: str + ogrn: str | None + kpp: str | None + legal_address: str | None + actual_address: str | None + bank_details: dict[str, Any] | None + contact_person: str | None + contact_phone: str | None + status: str + kyc_verified: bool + kyc_verified_at: datetime | None + encrypted_mnemonic: str | None + created_by: str | None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class OrganizationWalletEntity: + id: str + organization_id: str + chain: str + address: str + derivation_path: str + created_at: datetime | None = None + + +@dataclass +class OrganizationDocumentEntity: + id: str + organization_id: str + document_type: str + file_name: str + s3_key: str + content_type: str + file_size_bytes: int + uploaded_by: str | None + created_at: datetime | None = None + + +@dataclass +class PurchaseRequestEntity: + id: str + organization_id: str + status: str + usdt_amount: Decimal + rub_amount: Decimal | None + exchange_rate: Decimal | None + service_fee_percent: Decimal | None + comment: str | None + admin_comment: str | None + target_wallet_chain: str | None + target_wallet_address: str | None + tx_hash: str | None + assigned_to: str | None + created_at: datetime | None = None + updated_at: datetime | None = None + completed_at: datetime | None = None diff --git a/src/application/domain/entities/user.py b/src/application/domain/entities/user.py new file mode 100644 index 0000000..81457a4 --- /dev/null +++ b/src/application/domain/entities/user.py @@ -0,0 +1,34 @@ +from __future__ import annotations +from dataclasses import dataclass +from datetime import date, datetime + + +@dataclass(slots=True) +class UserEntity: + id: str | None = None + email: str | None = None + password_hash: str | None = None + + first_name: str | None = None + middle_name: str | None = None + last_name: str | None = None + birth_date: date | None = None + + encrypted_mnemonic: str | None = None + phone: str | None = None + + passport_data: str | None = None + inn: str | None = None + erc20: str | None = None + + avatar_link: str | None = None + + kyc_verified: bool | None = None + is_deleted: bool | None = None + + created_at: datetime | None = None + updated_at: datetime | None = None + kyc_verified_at: datetime | None = None + account_type: str | None = None + provisioned_by: str | None = None + provisioned_at: datetime | None = None diff --git a/src/application/domain/enums/__init__.py b/src/application/domain/enums/__init__.py new file mode 100644 index 0000000..f2785a9 --- /dev/null +++ b/src/application/domain/enums/__init__.py @@ -0,0 +1,2 @@ +from src.application.domain.enums.log_level import LogLevel +from src.application.domain.enums.log_format import LogFormat \ No newline at end of file diff --git a/src/application/domain/enums/account_type.py b/src/application/domain/enums/account_type.py new file mode 100644 index 0000000..abc5671 --- /dev/null +++ b/src/application/domain/enums/account_type.py @@ -0,0 +1,6 @@ +from enum import StrEnum + + +class AccountType(StrEnum): + INDIVIDUAL = 'individual' + LEGAL_ENTITY = 'legal_entity' diff --git a/src/application/domain/enums/admin_role.py b/src/application/domain/enums/admin_role.py new file mode 100644 index 0000000..759e6f7 --- /dev/null +++ b/src/application/domain/enums/admin_role.py @@ -0,0 +1,7 @@ +from enum import StrEnum + + +class AdminRole(StrEnum): + OPERATOR = 'operator' + COMPLIANCE = 'compliance' + SUPERADMIN = 'superadmin' diff --git a/src/application/domain/enums/log_format.py b/src/application/domain/enums/log_format.py new file mode 100644 index 0000000..b67feab --- /dev/null +++ b/src/application/domain/enums/log_format.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class LogFormat(Enum): + """Enum for supported log formats""" + TEXT = 'text' + JSON = 'json' diff --git a/src/application/domain/enums/log_level.py b/src/application/domain/enums/log_level.py new file mode 100644 index 0000000..be1bc17 --- /dev/null +++ b/src/application/domain/enums/log_level.py @@ -0,0 +1,54 @@ +from enum import Enum + + +class LogLevel(Enum): + DEBUG = 10 + INFO = 20 + WARNING = 30 + ERROR = 40 + CRITICAL = 50 + EXCEPTION = 60 + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"[{self.value}, '{self.name}']" + + def __eq__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value == other.value + if isinstance(other, int): + return self.value == other + return NotImplemented + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __lt__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value < other.value + if isinstance(other, int): + return self.value < other + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value <= other.value + if isinstance(other, int): + return self.value <= other + return NotImplemented + + def __gt__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value > other.value + if isinstance(other, int): + return self.value > other + return NotImplemented + + def __ge__(self, other: object) -> bool: + if isinstance(other, LogLevel): + return self.value >= other.value + if isinstance(other, int): + return self.value >= other + return NotImplemented diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py new file mode 100644 index 0000000..67deede --- /dev/null +++ b/src/application/domain/exceptions/__init__.py @@ -0,0 +1,10 @@ +from src.application.domain.exceptions.application_exception import ApplicationException +from src.application.domain.exceptions.bad_request_exception import BadRequestException +from src.application.domain.exceptions.conflict_exception import ConflictException +from src.application.domain.exceptions.forbidden_exception import ForbiddenException +from src.application.domain.exceptions.internal_server_exception import InternalServerException +from src.application.domain.exceptions.not_found_exception import NotFoundException +from src.application.domain.exceptions.service_unavailable_exception import ServiceUnavailableException +from src.application.domain.exceptions.too_many_requests_exception import TooManyRequestsException +from src.application.domain.exceptions.unauthorized_exception import UnauthorizedException +from src.application.domain.exceptions.refresh_concurrent_exception import RefreshConcurrentException \ No newline at end of file diff --git a/src/application/domain/exceptions/application_exception.py b/src/application/domain/exceptions/application_exception.py new file mode 100644 index 0000000..7396ceb --- /dev/null +++ b/src/application/domain/exceptions/application_exception.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from typing import Mapping + + +class ApplicationException(Exception): + def __init__( + self, + status_code: int, + message: str, + headers: Mapping[str, str] | None = None, + ): + super().__init__(message) + self.status_code = status_code + self.message = message + self.headers = headers + + def __str__(self): + return f'{self.status_code}: {self.message}' diff --git a/src/application/domain/exceptions/bad_request_exception.py b/src/application/domain/exceptions/bad_request_exception.py new file mode 100644 index 0000000..10eb9be --- /dev/null +++ b/src/application/domain/exceptions/bad_request_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class BadRequestException(ApplicationException): + def __init__( + self, + message: str = 'Bad Request', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/conflict_exception.py b/src/application/domain/exceptions/conflict_exception.py new file mode 100644 index 0000000..b276930 --- /dev/null +++ b/src/application/domain/exceptions/conflict_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ConflictException(ApplicationException): + def __init__( + self, + message: str = 'Conflict', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_409_CONFLICT, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/forbidden_exception.py b/src/application/domain/exceptions/forbidden_exception.py new file mode 100644 index 0000000..fe24741 --- /dev/null +++ b/src/application/domain/exceptions/forbidden_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ForbiddenException(ApplicationException): + def __init__( + self, + message: str = 'Forbidden', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_403_FORBIDDEN, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/internal_server_exception.py b/src/application/domain/exceptions/internal_server_exception.py new file mode 100644 index 0000000..eae4f00 --- /dev/null +++ b/src/application/domain/exceptions/internal_server_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class InternalServerException(ApplicationException): + def __init__( + self, + message: str = 'Internal Server Error', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/not_found_exception.py b/src/application/domain/exceptions/not_found_exception.py new file mode 100644 index 0000000..175686b --- /dev/null +++ b/src/application/domain/exceptions/not_found_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class NotFoundException(ApplicationException): + def __init__( + self, + message: str = 'Not Found', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/refresh_concurrent_exception.py b/src/application/domain/exceptions/refresh_concurrent_exception.py new file mode 100644 index 0000000..6885c74 --- /dev/null +++ b/src/application/domain/exceptions/refresh_concurrent_exception.py @@ -0,0 +1,10 @@ +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class RefreshConcurrentException(ApplicationException): + def __init__(self) -> None: + super().__init__( + status_code=status.HTTP_200_OK, + message='Refresh already handled', + ) diff --git a/src/application/domain/exceptions/service_unavailable_exception.py b/src/application/domain/exceptions/service_unavailable_exception.py new file mode 100644 index 0000000..067a638 --- /dev/null +++ b/src/application/domain/exceptions/service_unavailable_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class ServiceUnavailableException(ApplicationException): + def __init__( + self, + message: str = 'Service Unavailable', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/too_many_requests_exception.py b/src/application/domain/exceptions/too_many_requests_exception.py new file mode 100644 index 0000000..9b88c3c --- /dev/null +++ b/src/application/domain/exceptions/too_many_requests_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class TooManyRequestsException(ApplicationException): + def __init__( + self, + message: str = 'Too Many Requests', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + message=message, + headers=headers, + ) diff --git a/src/application/domain/exceptions/unauthorized_exception.py b/src/application/domain/exceptions/unauthorized_exception.py new file mode 100644 index 0000000..364caa4 --- /dev/null +++ b/src/application/domain/exceptions/unauthorized_exception.py @@ -0,0 +1,16 @@ +from typing import Mapping +from starlette import status +from src.application.domain.exceptions.application_exception import ApplicationException + + +class UnauthorizedException(ApplicationException): + def __init__( + self, + message: str = 'Unauthorized', + headers: Mapping[str, str] | None = None, + ): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + message=message, + headers=headers, + ) diff --git a/src/infrastructure/cache/__init__.py b/src/infrastructure/cache/__init__.py new file mode 100644 index 0000000..552c4f5 --- /dev/null +++ b/src/infrastructure/cache/__init__.py @@ -0,0 +1,2 @@ +from src.infrastructure.cache.client import create_redis_client +from src.infrastructure.cache.keydb_client import KeydbCache \ No newline at end of file diff --git a/src/infrastructure/cache/client.py b/src/infrastructure/cache/client.py new file mode 100644 index 0000000..614a935 --- /dev/null +++ b/src/infrastructure/cache/client.py @@ -0,0 +1,18 @@ +import redis.asyncio as redis +from redis.asyncio.client import Redis +from src.infrastructure.config import settings + + +def create_redis_client() -> Redis: + kw = { + 'max_connections': 50, + 'decode_responses': True, + 'socket_timeout': 5, + 'socket_connect_timeout': 5, + 'health_check_interval': 30, + 'retry_on_timeout': True, + 'socket_keepalive': True, + } + if settings.REDIS_PASSWORD: + kw['password'] = settings.REDIS_PASSWORD + return redis.from_url(settings.REDIS_URL, **kw) \ No newline at end of file diff --git a/src/infrastructure/cache/keydb_client.py b/src/infrastructure/cache/keydb_client.py new file mode 100644 index 0000000..1ec8b1d --- /dev/null +++ b/src/infrastructure/cache/keydb_client.py @@ -0,0 +1,20 @@ +from redis.asyncio.client import Redis +from src.application.contracts import ICache + + +class KeydbCache(ICache): + def __init__(self, redis_client: Redis): + self._r = redis_client + + async def set(self, key: str, value: str, ttl: int) -> None: + return bool(await self._r.set(key, value, ex=ttl)) + + async def set_nx(self, key: str, value: str, ttl: int) -> bool: + return bool(await self._r.set(key, value, ex=ttl, nx=True)) + + async def get(self, key: str) -> str | None: + return await self._r.get(key) + + async def delete(self, key: str) -> bool: + deleted = await self._r.delete(key) + return deleted > 0 diff --git a/src/infrastructure/config/__init__.py b/src/infrastructure/config/__init__.py new file mode 100644 index 0000000..4fb5df4 --- /dev/null +++ b/src/infrastructure/config/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.config.settings import settings diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py new file mode 100644 index 0000000..f19a678 --- /dev/null +++ b/src/infrastructure/config/settings.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import List, Literal +import os +from dotenv import load_dotenv, find_dotenv +from pydantic import AliasChoices, Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from src.infrastructure.vault import create_hvac_client_from_approle, read_kv2_secret + +env_file = find_dotenv(".env") +if env_file: + load_dotenv(env_file) + + +def normalize_vault_base_url(raw: str) -> str: + u = raw.strip().rstrip('/') + if not u: + return raw.strip() + if '://' not in u: + return f'https://{u}' + return u + + +class Settings(BaseSettings): + VAULT_ADDR: str = Field(default='http://localhost:8200') + VAULT_ROLE_ID: str = Field(..., description='AppRole role_id') + VAULT_SECRET_ID: str = Field( + ..., + description='AppRole secret_id', + validation_alias=AliasChoices('VAULT_SECRET_ID', 'VAULT_SECRET_TOKEN'), + ) + VAULT_NAMESPACE: str | None = Field(default=None) + VAULT_MOUNT_POINT: str = Field(default='dev-secrets') + + VAULT_JWT_KID_PATH: str = 'jwt/kid' + VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids' + VAULT_CRYPTO_MASTER_KEY_PATH: str = 'crypto/master' + VAULT_LEGAL_DOCS_S3_SECRET_PATH: str = 's3/legal-docs' + JWT_KEYS_REFRESH_SECONDS: int = 3600 + + DATABASE_HOST: str + DATABASE_PORT: int = Field(default=5432, ge=1, le=65535) + DATABASE_NAME: str + DATABASE_USER: str + DATABASE_PASSWORD: str + + DATABASE_POOL_SIZE: int = 10 + DATABASE_MAX_OVERFLOW: int = 20 + DATABASE_POOL_TIMEOUT: int = 30 + DATABASE_POOL_RECYCLE: int = 3600 + DATABASE_ECHO: bool = False + + ADMIN_COOKIE_SECURE: bool = False + ADMIN_COOKIE_DOMAIN: str | None = '.elcsa.ru' + CORS_ALLOW_ORIGIN_REGEX: str = r'https?://([a-z0-9-]+\.)*admin\.elcsa\.ru(:\d+)?$' + + DOCS_USERNAME: str = 'admin' + DOCS_PASSWORD: str = 'admin' + + JWT_ACCESS_TTL_SECONDS: int = 8 * 60 * 60 + ADMIN_JWT_ISSUER: str | None = 'admin-service' + JWT_AUDIENCE: str | None = None + JWT_ALGORITHM: str = 'RS256' + + REDIS_HOST: str = 'localhost' + REDIS_PORT: int = 6379 + REDIS_PASSWORD: str | None = None + REDIS_DB: int = 0 + + LEGAL_DOCS_S3_BUCKET: str = '' + LEGAL_DOCS_S3_REGION: str = 'us-east-1' + LEGAL_DOCS_S3_ACCESS_KEY_ID: str = '' + LEGAL_DOCS_S3_SECRET_ACCESS_KEY: str = '' + LEGAL_DOCS_S3_ENDPOINT_URL: str = '' + LEGAL_DOCS_S3_KEY_PREFIX: str = 'legal-docs' + LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS: int = 3600 + + RATE_LIMIT_REQUESTS: int = 60 + RATE_LIMIT_WINDOW: int = 60 + + LOG_LEVEL: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO' + LOG_FORMAT: Literal['JSON', 'TEXT'] = 'TEXT' + + @field_validator('VAULT_ADDR', mode='before') + @classmethod + def vault_addr_scheme(cls, v): + if v is None or not isinstance(v, str): + return v + return normalize_vault_base_url(v) + + @field_validator('ADMIN_COOKIE_DOMAIN', mode='before') + @classmethod + def normalize_admin_cookie_domain(cls, v): + if v is None or (isinstance(v, str) and not v.strip()): + return '.elcsa.ru' + s = str(v).strip() + sl = s.lower() + if sl in ('.elcsa.ru', 'elcsa.ru'): + return '.elcsa.ru' + if sl.endswith('.elcsa.ru') and not sl.startswith('.'): + return '.elcsa.ru' + return s + + @field_validator('REDIS_PASSWORD', mode='before') + @classmethod + def empty_redis_password_to_none(cls, v): + if v is None or (isinstance(v, str) and not v.strip()): + return None + return v + + model_config = SettingsConfigDict( + env_file='.env', + env_file_encoding='utf-8', + case_sensitive=True, + extra='ignore', + populate_by_name=True, + ) + + @staticmethod + def _vault_kv(mapping: dict, *keys: str): + for k in keys: + if k in mapping and mapping[k] is not None: + return mapping[k] + return None + + @classmethod + def _apply_s3_from_vault(cls, data: dict, s3: dict) -> None: + bucket = cls._vault_kv(s3, 'bucket_name', 'BUCKET_NAME', 'bucket', 'LEGAL_DOCS_S3_BUCKET') + endpoint = cls._vault_kv(s3, 's3_endpoint_url', 'S3_ENDPOINT_URL', 'endpoint_url', 'LEGAL_DOCS_S3_ENDPOINT_URL') + ak = cls._vault_kv(s3, 's3_access_key_id', 'S3_ACCESS_KEY_ID', 'ACCESS_KEY_ID', 'LEGAL_DOCS_S3_ACCESS_KEY_ID') + sk = cls._vault_kv( + s3, 's3_secret_access_key', 'S3_SECRET_ACCESS_KEY', 'SECRET_ACCESS_KEY', 'LEGAL_DOCS_S3_SECRET_ACCESS_KEY' + ) + if bucket: + data['LEGAL_DOCS_S3_BUCKET'] = str(bucket).strip() + if endpoint: + data['LEGAL_DOCS_S3_ENDPOINT_URL'] = str(endpoint).strip() + if ak: + data['LEGAL_DOCS_S3_ACCESS_KEY_ID'] = str(ak).strip() + if sk: + data['LEGAL_DOCS_S3_SECRET_ACCESS_KEY'] = str(sk).strip() + region = cls._vault_kv(s3, 's3_region', 'S3_REGION', 'region', 'LEGAL_DOCS_S3_REGION') + if region: + data['LEGAL_DOCS_S3_REGION'] = str(region).strip() + prefix = cls._vault_kv(s3, 'key_prefix', 'LEGAL_DOCS_S3_KEY_PREFIX', 's3_key_prefix') + if prefix: + data['LEGAL_DOCS_S3_KEY_PREFIX'] = str(prefix).strip() + + @model_validator(mode='before') + @classmethod + def load_from_vault(cls, data: dict): + if not isinstance(data, dict): + return data + addr_raw = data.get('VAULT_ADDR') or os.getenv('VAULT_ADDR') or 'http://localhost:8200' + addr = normalize_vault_base_url(addr_raw) + data['VAULT_ADDR'] = addr + role_id = data.get('VAULT_ROLE_ID') or os.getenv('VAULT_ROLE_ID') + secret_id = ( + data.get('VAULT_SECRET_ID') + or data.get('VAULT_SECRET_TOKEN') + or os.getenv('VAULT_SECRET_ID') + or os.getenv('VAULT_SECRET_TOKEN') + ) + namespace = data.get('VAULT_NAMESPACE') + if namespace is None: + namespace = os.getenv('VAULT_NAMESPACE') + namespace = namespace if namespace else None + mount = data.get('VAULT_MOUNT_POINT') or os.getenv('VAULT_MOUNT_POINT') or 'dev-secrets' + + if not role_id or not secret_id: + raise RuntimeError( + 'VAULT_ROLE_ID and VAULT_SECRET_ID (or VAULT_SECRET_TOKEN) are required for Vault AppRole' + ) + + data['VAULT_ROLE_ID'] = str(role_id).strip() + data['VAULT_SECRET_ID'] = str(secret_id).strip() + + client = create_hvac_client_from_approle( + url=addr, + role_id=role_id, + secret_id=secret_id, + namespace=namespace, + timeout=5, + ) + + def read_secret(path: str) -> dict: + return read_kv2_secret(client=client, mount_point=mount, path=path) + + def read_secret_optional(path: str) -> dict: + try: + return read_secret(path) + except Exception: + return {} + + database = read_secret('database') + db_ci = {str(k).lower(): v for k, v in database.items()} + + def db_nonempty(key: str) -> bool: + v = db_ci.get(key) + if v is None: + return False + if isinstance(v, str) and not v.strip(): + return False + return True + + required_db = ['host', 'name', 'user', 'password', 'port'] + missing_db = [k for k in required_db if not db_nonempty(k)] + if missing_db: + raise RuntimeError(f'Vault secret database missing non-empty keys: {missing_db}') + + data['DATABASE_HOST'] = str(db_ci['host']).strip() + data['DATABASE_PORT'] = int(db_ci['port']) + data['DATABASE_NAME'] = str(db_ci['name']).strip() + data['DATABASE_USER'] = str(db_ci['user']).strip() + data['DATABASE_PASSWORD'] = str(db_ci['password']).strip() + + redis_secret = read_secret_optional('redis') + if redis_secret: + rd_ci = {str(k).lower(): v for k, v in redis_secret.items()} + + def rd_set(field: str, env_key: str, *, as_int: bool = False) -> None: + v = rd_ci.get(field) + if v is None: + return + if isinstance(v, str) and not v.strip(): + return + if as_int: + data[env_key] = int(v) + else: + data[env_key] = str(v).strip() + + rd_set('host', 'REDIS_HOST') + rd_set('port', 'REDIS_PORT', as_int=True) + rd_set('password', 'REDIS_PASSWORD') + rd_set('db', 'REDIS_DB', as_int=True) + + s3_path = ( + data.get('VAULT_LEGAL_DOCS_S3_SECRET_PATH') + or os.getenv('VAULT_LEGAL_DOCS_S3_SECRET_PATH') + or 's3/legal-docs' + ) + s3_secret = read_secret_optional(str(s3_path)) + if s3_secret: + cls._apply_s3_from_vault(data, s3_secret) + + return data + + @property + def DATABASE_URL(self) -> str: + return ( + f"postgresql+asyncpg://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}" + f"@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}" + ) + + @property + def REDIS_URL(self) -> str: + return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + + @property + def EXCLUDED_PATHS(self) -> List[str]: + return ['/docs', '/redoc', '/openapi.json', '/ping', '/health'] + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/src/infrastructure/context_vars/__init__.py b/src/infrastructure/context_vars/__init__.py new file mode 100644 index 0000000..b68ba6f --- /dev/null +++ b/src/infrastructure/context_vars/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.context_vars.trace_id import trace_id_var \ No newline at end of file diff --git a/src/infrastructure/context_vars/trace_id.py b/src/infrastructure/context_vars/trace_id.py new file mode 100644 index 0000000..ec63d65 --- /dev/null +++ b/src/infrastructure/context_vars/trace_id.py @@ -0,0 +1,4 @@ +from contextvars import ContextVar + + +trace_id_var: ContextVar[str] = ContextVar('trace_id', default='N/A') diff --git a/src/infrastructure/crypto/__init__.py b/src/infrastructure/crypto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/crypto/wallet_crypto.py b/src/infrastructure/crypto/wallet_crypto.py new file mode 100644 index 0000000..da47c2d --- /dev/null +++ b/src/infrastructure/crypto/wallet_crypto.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import base64 +import os +import re +from dataclasses import dataclass + +from bip_utils import ( + Bip39MnemonicGenerator, + Bip39SeedGenerator, + Bip39WordsNum, + Bip44, + Bip44Changes, + Bip44Coins, + Bip84, + Bip84Coins, +) +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret + +KEY_LEN = 32 +IV_LEN = 12 +TAG_LEN = 16 + +DERIVATION_PATHS: dict[str, str] = { + 'ETH': "m/44'/60'/0'/0/0", + 'BSC': "m/44'/60'/0'/0/0", + 'BTC': "m/84'/0'/0'/0/0", + 'TRX': "m/44'/195'/0'/0/0", + 'SOL': "m/44'/501'/0'/0'", +} + +ALL_CHAINS: tuple[str, ...] = ('ETH', 'BSC', 'BTC', 'TRX', 'SOL') + +_master_key: bytes | None = None + + +@dataclass(frozen=True) +class DerivedWallet: + chain: str + address: str + derivation_path: str + + +class CryptoNotReadyError(RuntimeError): + pass + + +def load_master_key_from_vault( + *, + vault_addr: str, + vault_role_id: str, + vault_secret_id: str, + vault_namespace: str | None, + mount_point: str, + path: str, +) -> None: + global _master_key + if _master_key is not None: + return + + client = create_hvac_client_from_approle( + url=vault_addr, + role_id=vault_role_id, + secret_id=vault_secret_id, + namespace=vault_namespace, + timeout=5, + ) + secrets = read_kv2_secret(client=client, mount_point=mount_point, path=path) + if not secrets: + raise RuntimeError(f'Failed to load crypto master key from Vault path {path}') + + raw = secrets.get('key') + if not raw or not isinstance(raw, str): + if secrets.get('master_key') or secrets.get('MASTER_KEY'): + raise RuntimeError('Crypto master key misconfigured: alternate field present but canonical "key" missing') + raise RuntimeError('Crypto master key invalid: expected hex string in Vault field "key"') + + if not re.fullmatch(r'[0-9a-fA-F]{64}', raw): + raise RuntimeError('Crypto master key invalid: must be 64-char hex (32 bytes)') + + key = bytes.fromhex(raw) + if len(key) != KEY_LEN: + raise RuntimeError(f'Crypto master key invalid: got {len(key)} bytes, expected {KEY_LEN}') + + _master_key = key + + +def is_crypto_ready() -> bool: + return _master_key is not None and len(_master_key) == KEY_LEN + + +def generate_mnemonic() -> str: + return str(Bip39MnemonicGenerator().FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)) + + +def derive_all_addresses(mnemonic: str) -> list[DerivedWallet]: + seed_bytes = Bip39SeedGenerator(mnemonic).Generate() + + eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM) + eth_addr = ( + eth_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + .PublicKey() + .ToAddress() + ) + + btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN) + btc_addr = ( + btc_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + .PublicKey() + .ToAddress() + ) + + trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON) + trx_addr = ( + trx_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + .PublicKey() + .ToAddress() + ) + + sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA) + sol_addr = ( + sol_ctx.Purpose() + .Coin() + .Account(0) + .Change(Bip44Changes.CHAIN_EXT) + .AddressIndex(0) + .PublicKey() + .ToAddress() + ) + + return [ + DerivedWallet(chain='ETH', address=eth_addr, derivation_path=DERIVATION_PATHS['ETH']), + DerivedWallet(chain='BSC', address=eth_addr, derivation_path=DERIVATION_PATHS['BSC']), + DerivedWallet(chain='BTC', address=btc_addr, derivation_path=DERIVATION_PATHS['BTC']), + DerivedWallet(chain='TRX', address=trx_addr, derivation_path=DERIVATION_PATHS['TRX']), + DerivedWallet(chain='SOL', address=sol_addr, derivation_path=DERIVATION_PATHS['SOL']), + ] + + +def encrypt_mnemonic(plaintext: str) -> str: + if _master_key is None: + raise CryptoNotReadyError('Crypto service not ready') + if not plaintext: + raise ValueError('encrypt_mnemonic: plaintext must be non-empty') + + iv = os.urandom(IV_LEN) + aesgcm = AESGCM(_master_key) + ct_with_tag = aesgcm.encrypt(iv, plaintext.encode('utf-8'), None) + tag = ct_with_tag[-TAG_LEN:] + ct = ct_with_tag[:-TAG_LEN] + return base64.b64encode(iv + ct + tag).decode('ascii') + + +def decrypt_mnemonic(blob: str) -> str: + if _master_key is None: + raise CryptoNotReadyError('Crypto service not ready') + if not blob: + raise ValueError('decrypt_mnemonic: blob must be non-empty') + + raw = base64.b64decode(blob) + if len(raw) < IV_LEN + TAG_LEN + 1: + raise ValueError('decrypt_mnemonic: blob too short') + + iv = raw[:IV_LEN] + tag = raw[-TAG_LEN:] + ct = raw[IV_LEN:-TAG_LEN] + aesgcm = AESGCM(_master_key) + try: + return aesgcm.decrypt(iv, ct + tag, None).decode('utf-8') + except Exception as exc: + raise ValueError('decrypt_mnemonic: authentication failed') from exc diff --git a/src/infrastructure/database/__init__.py b/src/infrastructure/database/__init__.py new file mode 100644 index 0000000..71393f5 --- /dev/null +++ b/src/infrastructure/database/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.database.unit_of_work import UnitOfWork \ No newline at end of file diff --git a/src/infrastructure/database/context.py b/src/infrastructure/database/context.py new file mode 100644 index 0000000..c3ac8e9 --- /dev/null +++ b/src/infrastructure/database/context.py @@ -0,0 +1,22 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio.engine import create_async_engine +from sqlalchemy.ext.asyncio.session import AsyncSession +from typing import AsyncGenerator +from src.infrastructure.config import settings + + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_timeout=settings.DATABASE_POOL_TIMEOUT, + pool_recycle=settings.DATABASE_POOL_RECYCLE, + echo=settings.DATABASE_ECHO +) + +async_session_maker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session \ No newline at end of file diff --git a/src/infrastructure/database/decorators/__init__.py b/src/infrastructure/database/decorators/__init__.py new file mode 100644 index 0000000..02df9d0 --- /dev/null +++ b/src/infrastructure/database/decorators/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.database.decorators.transactional import transactional \ No newline at end of file diff --git a/src/infrastructure/database/decorators/transactional.py b/src/infrastructure/database/decorators/transactional.py new file mode 100644 index 0000000..b472d47 --- /dev/null +++ b/src/infrastructure/database/decorators/transactional.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from functools import wraps +from typing import Callable, Awaitable, TypeVar, ParamSpec + + +P = ParamSpec("P") +R = TypeVar("R") + + +def transactional(method: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + @wraps(method) + async def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R: + async with self._unit_of_work: + return await method(self, *args, **kwargs) + return wrapper diff --git a/src/infrastructure/database/models/__init__.py b/src/infrastructure/database/models/__init__.py new file mode 100644 index 0000000..7f95d1c --- /dev/null +++ b/src/infrastructure/database/models/__init__.py @@ -0,0 +1,19 @@ +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.user import UserModel +from src.infrastructure.database.models.admin_user import AdminUserModel +from src.infrastructure.database.models.admin_session import AdminSessionModel +from src.infrastructure.database.models.legal_entity import LegalEntityModel +from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel +from src.infrastructure.database.models.organization_document import OrganizationDocumentModel +from src.infrastructure.database.models.purchase_request import PurchaseRequestModel + +__all__ = [ + 'Base', + 'UserModel', + 'AdminUserModel', + 'AdminSessionModel', + 'LegalEntityModel', + 'OrganizationWalletModel', + 'OrganizationDocumentModel', + 'PurchaseRequestModel', +] diff --git a/src/infrastructure/database/models/admin_session.py b/src/infrastructure/database/models/admin_session.py new file mode 100644 index 0000000..df24c44 --- /dev/null +++ b/src/infrastructure/database/models/admin_session.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, Index, String +from sqlalchemy.orm import Mapped, mapped_column +from ulid import ULID + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class AdminSessionModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'admin_sessions' + + sid: Mapped[str] = mapped_column( + String(26), + unique=True, + index=True, + nullable=False, + default=lambda: str(ULID()), + ) + admin_user_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('admin_users.id', ondelete='CASCADE'), + index=True, + nullable=False, + ) + device_id: Mapped[str] = mapped_column(String(26), nullable=False, index=True) + user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True) + first_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_seen_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + refresh_jti_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +Index('ux_admin_sessions_user_device', AdminSessionModel.admin_user_id, AdminSessionModel.device_id, unique=True) diff --git a/src/infrastructure/database/models/admin_user.py b/src/infrastructure/database/models/admin_user.py new file mode 100644 index 0000000..c201166 --- /dev/null +++ b/src/infrastructure/database/models/admin_user.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class AdminUserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'admin_users' + + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + first_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + role: Mapped[str] = mapped_column(String(32), nullable=False, server_default='operator', default='operator') + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/src/infrastructure/database/models/base.py b/src/infrastructure/database/models/base.py new file mode 100644 index 0000000..aaed4e3 --- /dev/null +++ b/src/infrastructure/database/models/base.py @@ -0,0 +1,19 @@ +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlalchemy.orm import DeclarativeBase + + +class Base(AsyncAttrs, DeclarativeBase): + __abstract__ = True + + def __repr__(self) -> str: + class_name = self.__class__.__name__ + attributes = ', '.join(f"{col.name}={getattr(self, col.name, None)!r}" + for col in self.__table__.columns) + return f"<{class_name}({attributes})>" + + def __str__(self) -> str: + class_name = self.__class__.__name__ + attributes = ', '.join(f"{col.name}={getattr(self, col.name)}" + for col in self.__table__.columns + if getattr(self, col.name) is not None) + return f"{class_name}({attributes})" \ No newline at end of file diff --git a/src/infrastructure/database/models/legal_entity.py b/src/infrastructure/database/models/legal_entity.py new file mode 100644 index 0000000..2486991 --- /dev/null +++ b/src/infrastructure/database/models/legal_entity.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'legal_entities' + + user_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('users.id', ondelete='RESTRICT'), + nullable=False, + unique=True, + index=True, + ) + name: Mapped[str] = mapped_column(String(512), nullable=False) + short_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + inn: Mapped[str] = mapped_column(String(12), nullable=False, index=True) + ogrn: Mapped[str | None] = mapped_column(String(15), nullable=True) + kpp: Mapped[str | None] = mapped_column(String(9), nullable=True) + legal_address: Mapped[str | None] = mapped_column(Text, nullable=True) + actual_address: Mapped[str | None] = mapped_column(Text, nullable=True) + bank_details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + contact_person: Mapped[str | None] = mapped_column(String(256), nullable=True) + contact_phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='active', default='active') + kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True) + kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column( + String(26), + ForeignKey('admin_users.id'), + nullable=True, + ) diff --git a/src/infrastructure/database/models/mixins/__init__.py b/src/infrastructure/database/models/mixins/__init__.py new file mode 100644 index 0000000..b69a0df --- /dev/null +++ b/src/infrastructure/database/models/mixins/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.database.models.mixins.audit import AuditTimestampsMixin +from src.infrastructure.database.models.mixins.ulid import UlidPrimaryKeyMixin +from src.infrastructure.database.models.mixins.soft_delete import SoftDeleteMixin \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/audit.py b/src/infrastructure/database/models/mixins/audit.py new file mode 100644 index 0000000..c3f143d --- /dev/null +++ b/src/infrastructure/database/models/mixins/audit.py @@ -0,0 +1,16 @@ +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + + +class AuditTimestampsMixin: + created_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/soft_delete.py b/src/infrastructure/database/models/mixins/soft_delete.py new file mode 100644 index 0000000..ca6a14a --- /dev/null +++ b/src/infrastructure/database/models/mixins/soft_delete.py @@ -0,0 +1,6 @@ +from sqlalchemy import Boolean +from sqlalchemy.orm import Mapped, mapped_column + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False) \ No newline at end of file diff --git a/src/infrastructure/database/models/mixins/ulid.py b/src/infrastructure/database/models/mixins/ulid.py new file mode 100644 index 0000000..1d272ef --- /dev/null +++ b/src/infrastructure/database/models/mixins/ulid.py @@ -0,0 +1,8 @@ +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column +from ulid import ULID + + +class UlidPrimaryKeyMixin: + + id: Mapped[str] = mapped_column(String(26), primary_key=True, default=lambda: str(ULID())) diff --git a/src/infrastructure/database/models/organization_document.py b/src/infrastructure/database/models/organization_document.py new file mode 100644 index 0000000..e7dbbb0 --- /dev/null +++ b/src/infrastructure/database/models/organization_document.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin + + +class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin): + __tablename__ = 'organization_documents' + + organization_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('legal_entities.id', ondelete='RESTRICT'), + nullable=False, + index=True, + ) + document_type: Mapped[str] = mapped_column(String(64), nullable=False) + file_name: Mapped[str] = mapped_column(String(512), nullable=False) + s3_key: Mapped[str] = mapped_column(String(1024), nullable=False) + content_type: Mapped[str] = mapped_column(String(128), nullable=False) + file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + uploaded_by: Mapped[str | None] = mapped_column( + String(26), + ForeignKey('admin_users.id'), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/src/infrastructure/database/models/organization_wallet.py b/src/infrastructure/database/models/organization_wallet.py new file mode 100644 index 0000000..a06cf44 --- /dev/null +++ b/src/infrastructure/database/models/organization_wallet.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin + + +class OrganizationWalletModel(Base, UlidPrimaryKeyMixin): + __tablename__ = 'organization_wallets' + + organization_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('legal_entities.id', ondelete='RESTRICT'), + nullable=False, + index=True, + ) + chain: Mapped[str] = mapped_column(String(16), nullable=False) + address: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + derivation_path: Mapped[str] = mapped_column(String(64), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/src/infrastructure/database/models/purchase_request.py b/src/infrastructure/database/models/purchase_request.py new file mode 100644 index 0000000..2b6caf7 --- /dev/null +++ b/src/infrastructure/database/models/purchase_request.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin + + +class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin): + __tablename__ = 'purchase_requests' + + organization_id: Mapped[str] = mapped_column( + String(26), + ForeignKey('legal_entities.id', ondelete='RESTRICT'), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted') + usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False) + rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) + exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True) + service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + admin_comment: Mapped[str | None] = mapped_column(Text, nullable=True) + target_wallet_chain: Mapped[str | None] = mapped_column(String(16), nullable=True, server_default='ETH') + target_wallet_address: Mapped[str | None] = mapped_column(String(128), nullable=True) + tx_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) + assigned_to: Mapped[str | None] = mapped_column( + String(26), + ForeignKey('admin_users.id'), + nullable=True, + ) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/src/infrastructure/database/models/user.py b/src/infrastructure/database/models/user.py new file mode 100644 index 0000000..fa5b23d --- /dev/null +++ b/src/infrastructure/database/models/user.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Date, String, DateTime, Text +from sqlalchemy.orm import Mapped,mapped_column +from src.infrastructure.database.models.base import Base +from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin + + +class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin): + __tablename__ = 'users' + + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + last_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + first_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + middle_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True) + + encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True) + phone: Mapped[str | None] = mapped_column(String(16), nullable=True) + + passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True) + inn: Mapped[str | None] = mapped_column(String(12), nullable=True) + erc20: Mapped[str | None] = mapped_column(String(255), nullable=True) + + avatar_link: Mapped[str | None] = mapped_column(Text, nullable=True) + + kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False) + kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/src/infrastructure/database/repositories/__init__.py b/src/infrastructure/database/repositories/__init__.py new file mode 100644 index 0000000..a843878 --- /dev/null +++ b/src/infrastructure/database/repositories/__init__.py @@ -0,0 +1,17 @@ +from src.infrastructure.database.repositories.admin_user_repository import AdminUserRepository +from src.infrastructure.database.repositories.admin_session_repository import AdminSessionRepository +from src.infrastructure.database.repositories.user_repository import UserRepository +from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository +from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository +from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository +from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository + +__all__ = [ + 'AdminUserRepository', + 'AdminSessionRepository', + 'UserRepository', + 'LegalEntityRepository', + 'OrganizationWalletRepository', + 'OrganizationDocumentRepository', + 'PurchaseRequestRepository', +] diff --git a/src/infrastructure/database/repositories/admin_session_repository.py b/src/infrastructure/database/repositories/admin_session_repository.py new file mode 100644 index 0000000..e724b15 --- /dev/null +++ b/src/infrastructure/database/repositories/admin_session_repository.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.application.abstractions.repositories import IAdminSessionRepository +from src.application.contracts import ILogger +from src.application.domain.entities.admin_session import AdminSessionEntity +from src.infrastructure.database.models import AdminSessionModel + + +class AdminSessionRepository(IAdminSessionRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: AdminSessionModel) -> AdminSessionEntity: + return AdminSessionEntity( + sid=m.sid, + admin_user_id=m.admin_user_id, + device_id=m.device_id, + revoked_at=m.revoked_at, + last_seen_at=m.last_seen_at, + refresh_jti_hash=m.refresh_jti_hash, + refresh_expires_at=m.refresh_expires_at, + user_agent=m.user_agent, + first_ip=m.first_ip, + last_ip=m.last_ip, + ) + + async def get_by_sid(self, sid: str) -> Optional[AdminSessionEntity]: + res = await self._session.execute(select(AdminSessionModel).where(AdminSessionModel.sid == sid)) + m = res.scalar_one_or_none() + return self._to_entity(m) if m else None + + async def upsert_by_device( + self, + *, + admin_user_id: str, + device_id: str, + sid: str, + refresh_jti_hash: str, + refresh_expires_at: datetime, + user_agent: str | None, + ip: str | None, + now: datetime, + ) -> AdminSessionEntity: + res = await self._session.execute( + select(AdminSessionModel).where( + AdminSessionModel.admin_user_id == admin_user_id, + AdminSessionModel.device_id == device_id, + ) + ) + m = res.scalar_one_or_none() + if m is None: + m = AdminSessionModel( + sid=sid, + admin_user_id=admin_user_id, + device_id=device_id, + revoked_at=None, + last_seen_at=now, + refresh_jti_hash=refresh_jti_hash, + refresh_expires_at=refresh_expires_at, + user_agent=user_agent, + first_ip=ip, + last_ip=ip, + ) + self._session.add(m) + else: + m.sid = sid + m.revoked_at = None + m.last_seen_at = now + m.refresh_jti_hash = refresh_jti_hash + m.refresh_expires_at = refresh_expires_at + m.user_agent = user_agent + m.last_ip = ip + await self._session.flush() + return self._to_entity(m) + + async def revoke_by_sid(self, sid: str, now: datetime) -> None: + await self._session.execute( + update(AdminSessionModel) + .where(AdminSessionModel.sid == sid, AdminSessionModel.revoked_at.is_(None)) + .values(revoked_at=now) + ) + await self._session.flush() + + async def rotate_refresh_if_match( + self, + *, + sid: str, + old_jti_hash: str, + new_jti_hash: str, + new_refresh_expires_at: datetime, + now: datetime, + ip: str | None, + user_agent: str | None, + ) -> bool: + values = { + 'refresh_jti_hash': new_jti_hash, + 'refresh_expires_at': new_refresh_expires_at, + 'last_seen_at': now, + 'user_agent': user_agent, + } + if ip is not None: + values['last_ip'] = ip + res = await self._session.execute( + update(AdminSessionModel) + .where( + AdminSessionModel.sid == sid, + AdminSessionModel.revoked_at.is_(None), + AdminSessionModel.refresh_jti_hash == old_jti_hash, + ) + .values(**values) + ) + await self._session.flush() + return (res.rowcount or 0) > 0 diff --git a/src/infrastructure/database/repositories/admin_user_repository.py b/src/infrastructure/database/repositories/admin_user_repository.py new file mode 100644 index 0000000..406d908 --- /dev/null +++ b/src/infrastructure/database/repositories/admin_user_repository.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import status +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from src.application.abstractions.repositories import IAdminUserRepository +from src.application.contracts import ILogger +from src.application.domain.entities.admin_user import AdminUserEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import AdminUserModel + + +class AdminUserRepository(IAdminUserRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: AdminUserModel) -> AdminUserEntity: + return AdminUserEntity( + id=m.id, + email=m.email, + password_hash=m.password_hash, + first_name=m.first_name, + last_name=m.last_name, + role=m.role, + is_active=m.is_active, + last_login_at=m.last_login_at, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + async def get_by_email(self, email: str) -> AdminUserEntity: + try: + stmt = select(AdminUserModel).where(func.lower(AdminUserModel.email) == email.lower()) + result = await self._session.execute(stmt) + user = result.scalar_one_or_none() + if user is None: + raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found') + return self._to_entity(user) + except ApplicationException: + raise + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def get_by_id(self, admin_user_id: str) -> AdminUserEntity: + try: + stmt = select(AdminUserModel).where(AdminUserModel.id == admin_user_id) + result = await self._session.execute(stmt) + user = result.scalar_one_or_none() + if user is None: + raise ApplicationException(status_code=status.HTTP_404_NOT_FOUND, message='Admin user not found') + return self._to_entity(user) + except ApplicationException: + raise + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def update_last_login(self, admin_user_id: str, *, last_login_at: datetime) -> None: + await self._session.execute( + update(AdminUserModel) + .where(AdminUserModel.id == admin_user_id) + .values(last_login_at=last_login_at) + ) + await self._session.flush() diff --git a/src/infrastructure/database/repositories/legal_entity_repository.py b/src/infrastructure/database/repositories/legal_entity_repository.py new file mode 100644 index 0000000..567d019 --- /dev/null +++ b/src/infrastructure/database/repositories/legal_entity_repository.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import status +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from src.application.abstractions.repositories import ILegalEntityRepository +from src.application.contracts import ILogger +from src.application.domain.entities.organization import LegalEntityEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import LegalEntityModel + + +class LegalEntityRepository(ILegalEntityRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity: + return LegalEntityEntity( + id=m.id, + user_id=m.user_id, + name=m.name, + short_name=m.short_name, + inn=m.inn, + ogrn=m.ogrn, + kpp=m.kpp, + legal_address=m.legal_address, + actual_address=m.actual_address, + bank_details=m.bank_details, + contact_person=m.contact_person, + contact_phone=m.contact_phone, + status=m.status, + kyc_verified=m.kyc_verified, + kyc_verified_at=m.kyc_verified_at, + encrypted_mnemonic=m.encrypted_mnemonic, + created_by=m.created_by, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + async def create( + self, + *, + user_id: str, + name: str, + short_name: str | None, + inn: str, + ogrn: str | None, + kpp: str | None, + legal_address: str | None, + actual_address: str | None, + bank_details: dict[str, Any] | None, + contact_person: str | None, + contact_phone: str | None, + status: str, + kyc_verified: bool, + kyc_verified_at, + created_by: str | None, + ) -> LegalEntityEntity: + entity = LegalEntityModel( + user_id=user_id, + name=name, + short_name=short_name, + inn=inn, + ogrn=ogrn, + kpp=kpp, + legal_address=legal_address, + actual_address=actual_address, + bank_details=bank_details, + contact_person=contact_person, + contact_phone=contact_phone, + status=status, + kyc_verified=kyc_verified, + kyc_verified_at=kyc_verified_at, + created_by=created_by, + ) + self._session.add(entity) + try: + await self._session.flush() + return self._to_entity(entity) + except IntegrityError: + raise ApplicationException(status_code=409, message='Organization with this INN or user already exists') + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def get_by_id(self, organization_id: str) -> LegalEntityEntity: + res = await self._session.execute(select(LegalEntityModel).where(LegalEntityModel.id == organization_id)) + m = res.scalar_one_or_none() + if m is None: + raise ApplicationException(status_code=404, message='Organization not found') + return self._to_entity(m) + + async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]: + res = await self._session.execute( + select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset) + ) + return [self._to_entity(m) for m in res.scalars().all()] + + async def count_all(self) -> int: + res = await self._session.execute(select(func.count()).select_from(LegalEntityModel)) + return int(res.scalar_one()) + + async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity: + if not values: + return await self.get_by_id(organization_id) + await self._session.execute( + update(LegalEntityModel).where(LegalEntityModel.id == organization_id).values(**values) + ) + await self._session.flush() + return await self.get_by_id(organization_id) + + async def set_encrypted_mnemonic(self, organization_id: str, encrypted_mnemonic: str) -> None: + await self._session.execute( + update(LegalEntityModel) + .where(LegalEntityModel.id == organization_id) + .values(encrypted_mnemonic=encrypted_mnemonic) + ) + await self._session.flush() diff --git a/src/infrastructure/database/repositories/organization_document_repository.py b/src/infrastructure/database/repositories/organization_document_repository.py new file mode 100644 index 0000000..b4e39fa --- /dev/null +++ b/src/infrastructure/database/repositories/organization_document_repository.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from src.application.abstractions.repositories import IOrganizationDocumentRepository +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationDocumentEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import OrganizationDocumentModel + + +class OrganizationDocumentRepository(IOrganizationDocumentRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity: + return OrganizationDocumentEntity( + id=m.id, + organization_id=m.organization_id, + document_type=m.document_type, + file_name=m.file_name, + s3_key=m.s3_key, + content_type=m.content_type, + file_size_bytes=m.file_size_bytes, + uploaded_by=m.uploaded_by, + created_at=m.created_at, + ) + + async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity: + model = OrganizationDocumentModel( + id=document.id, + organization_id=document.organization_id, + document_type=document.document_type, + file_name=document.file_name, + s3_key=document.s3_key, + content_type=document.content_type, + file_size_bytes=document.file_size_bytes, + uploaded_by=document.uploaded_by, + ) + self._session.add(model) + try: + await self._session.flush() + return self._to_entity(model) + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity: + res = await self._session.execute( + select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id) + ) + m = res.scalar_one_or_none() + if m is None: + raise ApplicationException(status_code=404, message='Document not found') + return self._to_entity(m) + + async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]: + res = await self._session.execute( + select(OrganizationDocumentModel) + .where(OrganizationDocumentModel.organization_id == organization_id) + .order_by(OrganizationDocumentModel.created_at.desc()) + ) + return [self._to_entity(m) for m in res.scalars().all()] diff --git a/src/infrastructure/database/repositories/organization_wallet_repository.py b/src/infrastructure/database/repositories/organization_wallet_repository.py new file mode 100644 index 0000000..d11c4f8 --- /dev/null +++ b/src/infrastructure/database/repositories/organization_wallet_repository.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from src.application.abstractions.repositories import IOrganizationWalletRepository +from src.application.contracts import ILogger +from src.application.domain.entities.organization import OrganizationWalletEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import OrganizationWalletModel + + +class OrganizationWalletRepository(IOrganizationWalletRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: OrganizationWalletModel) -> OrganizationWalletEntity: + return OrganizationWalletEntity( + id=m.id, + organization_id=m.organization_id, + chain=m.chain, + address=m.address, + derivation_path=m.derivation_path, + created_at=m.created_at, + ) + + async def create_many(self, wallets: list[OrganizationWalletEntity]) -> list[OrganizationWalletEntity]: + models = [ + OrganizationWalletModel( + id=w.id, + organization_id=w.organization_id, + chain=w.chain, + address=w.address, + derivation_path=w.derivation_path, + ) + for w in wallets + ] + self._session.add_all(models) + try: + await self._session.flush() + return [self._to_entity(m) for m in models] + except IntegrityError: + raise ApplicationException(status_code=409, message='Wallets already exist for organization') + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def list_by_organization(self, organization_id: str) -> list[OrganizationWalletEntity]: + res = await self._session.execute( + select(OrganizationWalletModel) + .where(OrganizationWalletModel.organization_id == organization_id) + .order_by(OrganizationWalletModel.chain) + ) + return [self._to_entity(m) for m in res.scalars().all()] + + async def exists_for_organization(self, organization_id: str) -> bool: + res = await self._session.execute( + select(OrganizationWalletModel.id) + .where(OrganizationWalletModel.organization_id == organization_id) + .limit(1) + ) + return res.scalar_one_or_none() is not None diff --git a/src/infrastructure/database/repositories/purchase_request_repository.py b/src/infrastructure/database/repositories/purchase_request_repository.py new file mode 100644 index 0000000..27d481c --- /dev/null +++ b/src/infrastructure/database/repositories/purchase_request_repository.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import SQLAlchemyError + +from src.application.abstractions.repositories import IPurchaseRequestRepository +from src.application.contracts import ILogger +from src.application.domain.entities.organization import PurchaseRequestEntity +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import PurchaseRequestModel + + +class PurchaseRequestRepository(IPurchaseRequestRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, m: PurchaseRequestModel) -> PurchaseRequestEntity: + return PurchaseRequestEntity( + id=m.id, + organization_id=m.organization_id, + status=m.status, + usdt_amount=m.usdt_amount, + rub_amount=m.rub_amount, + exchange_rate=m.exchange_rate, + service_fee_percent=m.service_fee_percent, + comment=m.comment, + admin_comment=m.admin_comment, + target_wallet_chain=m.target_wallet_chain, + target_wallet_address=m.target_wallet_address, + tx_hash=m.tx_hash, + assigned_to=m.assigned_to, + created_at=m.created_at, + updated_at=m.updated_at, + completed_at=m.completed_at, + ) + + def _apply_filters(self, stmt, *, status: str | None, organization_id: str | None): + if status: + stmt = stmt.where(PurchaseRequestModel.status == status) + if organization_id: + stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id) + return stmt + + async def get_by_id(self, request_id: str) -> PurchaseRequestEntity: + res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id)) + m = res.scalar_one_or_none() + if m is None: + raise ApplicationException(status_code=404, message='Purchase request not found') + return self._to_entity(m) + + async def list_all( + self, + *, + status: str | None, + organization_id: str | None, + limit: int, + offset: int, + ) -> list[PurchaseRequestEntity]: + stmt = select(PurchaseRequestModel).order_by(PurchaseRequestModel.created_at.desc()) + stmt = self._apply_filters(stmt, status=status, organization_id=organization_id) + res = await self._session.execute(stmt.limit(limit).offset(offset)) + return [self._to_entity(m) for m in res.scalars().all()] + + async def count_all(self, *, status: str | None, organization_id: str | None) -> int: + stmt = select(func.count()).select_from(PurchaseRequestModel) + stmt = self._apply_filters(stmt, status=status, organization_id=organization_id) + res = await self._session.execute(stmt) + return int(res.scalar_one()) + + async def update(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity: + if not values: + return await self.get_by_id(request_id) + await self._session.execute( + update(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id).values(**values) + ) + await self._session.flush() + return await self.get_by_id(request_id) diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py new file mode 100644 index 0000000..0d694c2 --- /dev/null +++ b/src/infrastructure/database/repositories/user_repository.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +from src.application.abstractions.repositories import IUserRepository +from src.application.contracts import ILogger +from src.application.domain.entities import UserEntity +from src.application.domain.enums.account_type import AccountType +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.database.models import UserModel + + +class UserRepository(IUserRepository): + def __init__(self, session: AsyncSession, logger: ILogger): + self._session = session + self._logger = logger + + def _to_entity(self, user: UserModel) -> UserEntity: + return UserEntity( + id=user.id, + email=user.email, + password_hash=user.password_hash, + first_name=user.first_name, + middle_name=user.middle_name, + last_name=user.last_name, + birth_date=user.birth_date, + encrypted_mnemonic=user.encrypted_mnemonic, + phone=user.phone, + passport_data=user.passport_data, + inn=user.inn, + erc20=user.erc20, + avatar_link=user.avatar_link, + kyc_verified=user.kyc_verified, + is_deleted=user.is_deleted, + created_at=user.created_at, + updated_at=user.updated_at, + kyc_verified_at=user.kyc_verified_at, + account_type=user.account_type, + provisioned_by=user.provisioned_by, + provisioned_at=user.provisioned_at, + ) + + async def create_legal_entity_user( + self, + *, + email: str, + password_hash: str, + provisioned_by: str, + provisioned_at: datetime, + kyc_verified: bool, + kyc_verified_at: datetime, + ) -> UserEntity: + user = UserModel( + email=email, + password_hash=password_hash, + account_type=AccountType.LEGAL_ENTITY, + provisioned_by=provisioned_by, + provisioned_at=provisioned_at, + kyc_verified=kyc_verified, + kyc_verified_at=kyc_verified_at, + ) + self._session.add(user) + try: + await self._session.flush() + return self._to_entity(user) + except IntegrityError: + raise ApplicationException(status_code=409, message='User with this email already exists') + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def get_user_by_email(self, email: str) -> UserEntity: + try: + stmt = select(UserModel).where(UserModel.email == email, UserModel.is_deleted.is_(False)) + result = await self._session.execute(stmt) + user = result.scalar_one_or_none() + if user is None: + raise ApplicationException(status_code=404, message='User not found') + return self._to_entity(user) + except ApplicationException: + raise + except SQLAlchemyError as exc: + self._logger.exception(str(exc)) + raise ApplicationException(status_code=500, message='Database error') + + async def exists_by_email(self, email: str) -> bool: + stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() is not None diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py new file mode 100644 index 0000000..a4476f0 --- /dev/null +++ b/src/infrastructure/database/unit_of_work.py @@ -0,0 +1,105 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.application.abstractions import IUnitOfWork +from src.application.abstractions.repositories import ( + IAdminSessionRepository, + IAdminUserRepository, + ILegalEntityRepository, + IOrganizationDocumentRepository, + IOrganizationWalletRepository, + IPurchaseRequestRepository, + IUserRepository, +) +from src.application.contracts import ILogger +from src.application.domain.exceptions import RefreshConcurrentException +from src.infrastructure.database.repositories import ( + AdminSessionRepository, + AdminUserRepository, + LegalEntityRepository, + OrganizationDocumentRepository, + OrganizationWalletRepository, + PurchaseRequestRepository, + UserRepository, +) + + +class UnitOfWork(IUnitOfWork): + def __init__(self, session_factory: async_sessionmaker[AsyncSession], logger: ILogger): + self.session_factory = session_factory + self._session: AsyncSession | None = None + self._user_repository: IUserRepository | None = None + self._admin_user_repository: IAdminUserRepository | None = None + self._admin_session_repository: IAdminSessionRepository | None = None + self._legal_entity_repository: ILegalEntityRepository | None = None + self._organization_wallet_repository: IOrganizationWalletRepository | None = None + self._organization_document_repository: IOrganizationDocumentRepository | None = None + self._purchase_request_repository: IPurchaseRequestRepository | None = None + self._logger: ILogger = logger + + async def __aenter__(self): + self._session = self.session_factory() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if exc_type: + if not isinstance(exc_val, RefreshConcurrentException): + self._logger.error(str(exc_val)) + await self._session.rollback() + else: + await self._session.flush() + await self._session.commit() + await self._session.close() + + async def commit(self) -> None: + await self._session.commit() + + async def rollback(self) -> None: + await self._session.rollback() + + @property + def user_repository(self) -> IUserRepository: + if self._user_repository is None: + self._user_repository = UserRepository(session=self._session, logger=self._logger) + return self._user_repository + + @property + def admin_user_repository(self) -> IAdminUserRepository: + if self._admin_user_repository is None: + self._admin_user_repository = AdminUserRepository(session=self._session, logger=self._logger) + return self._admin_user_repository + + @property + def admin_session_repository(self) -> IAdminSessionRepository: + if self._admin_session_repository is None: + self._admin_session_repository = AdminSessionRepository(session=self._session, logger=self._logger) + return self._admin_session_repository + + @property + def legal_entity_repository(self) -> ILegalEntityRepository: + if self._legal_entity_repository is None: + self._legal_entity_repository = LegalEntityRepository(session=self._session, logger=self._logger) + return self._legal_entity_repository + + @property + def organization_wallet_repository(self) -> IOrganizationWalletRepository: + if self._organization_wallet_repository is None: + self._organization_wallet_repository = OrganizationWalletRepository( + session=self._session, logger=self._logger + ) + return self._organization_wallet_repository + + @property + def organization_document_repository(self) -> IOrganizationDocumentRepository: + if self._organization_document_repository is None: + self._organization_document_repository = OrganizationDocumentRepository( + session=self._session, logger=self._logger + ) + return self._organization_document_repository + + @property + def purchase_request_repository(self) -> IPurchaseRequestRepository: + if self._purchase_request_repository is None: + self._purchase_request_repository = PurchaseRequestRepository( + session=self._session, logger=self._logger + ) + return self._purchase_request_repository diff --git a/src/infrastructure/logger/__init__.py b/src/infrastructure/logger/__init__.py new file mode 100644 index 0000000..a6fee69 --- /dev/null +++ b/src/infrastructure/logger/__init__.py @@ -0,0 +1,28 @@ +from src.application.contracts import ILogger +from src.application.domain.enums import LogFormat +from src.application.domain.enums import LogLevel +from src.infrastructure.config.settings import settings +from src.infrastructure.logger.logger import Logger + +log_levels = { + 'DEBUG': LogLevel.DEBUG, + 'INFO': LogLevel.INFO, + 'WARNING': LogLevel.WARNING, + 'ERROR': LogLevel.ERROR, + 'CRITICAL': LogLevel.CRITICAL, + 'EXCEPTION': LogLevel.EXCEPTION, +} + +log_formats = { + 'JSON': LogFormat.JSON, + 'TEXT': LogFormat.TEXT, +} + +logger = Logger( + min_level=log_levels.get(settings.LOG_LEVEL, LogLevel.INFO), + log_format=log_formats.get(settings.LOG_FORMAT, LogFormat.JSON), +) + + +def get_logger() -> ILogger: + return logger diff --git a/src/infrastructure/logger/logger.py b/src/infrastructure/logger/logger.py new file mode 100644 index 0000000..0fc2c8d --- /dev/null +++ b/src/infrastructure/logger/logger.py @@ -0,0 +1,129 @@ +import traceback +import inspect +import sys +import json +from datetime import datetime +from typing import Callable, Optional, Any +from ulid import ULID +from src.application.contracts import ILogger +from src.application.domain.enums import LogFormat, LogLevel +from src.infrastructure.context_vars import trace_id_var + + +class Logger(ILogger): + _instance = None + __default_format = LogFormat.JSON + + def __new__(cls, *args: Any, **kwargs: Any) -> "Logger": + if cls._instance is None: + cls._instance = super(Logger, cls).__new__(cls) + return cls._instance + + def __init__( + self, + log_format: LogFormat = __default_format, + min_level: LogLevel = LogLevel.INFO, + id_generator: Optional[Callable[[], str]] = lambda: str(ULID()), + instance_id: str = "N/A", + ): + self.log_format = log_format + self.min_level = min_level + self.id_generator = id_generator + self.instance_id = instance_id + + def set_instance_id(self, instance_id: str) -> None: + self.instance_id = instance_id + + def get_instance_id(self) -> str: + return self.instance_id + + def set_format(self, log_format: LogFormat) -> None: + if not isinstance(log_format, LogFormat): + raise ValueError("Log format must be an instance of LogFormat enum") + self.log_format = log_format + + def set_min_level(self, level: LogLevel) -> None: + self.min_level = level + + def new_trace_id(self) -> str: + trace_id = str(ULID()) if self.id_generator is None else self.id_generator() + trace_id_var.set(trace_id) + return trace_id + + def set_trace_id(self, trace_id: str) -> None: + trace_id_var.set(trace_id) + + def get_trace_id(self) -> str: + return trace_id_var.get() + + def clear_trace_id(self) -> None: + trace_id_var.set("N/A") + + def _prepare_log_data(self, level: LogLevel, message: str) -> dict[str, Any]: + current_frame = inspect.currentframe() + if ( + current_frame + and current_frame.f_back + and current_frame.f_back.f_back + and current_frame.f_back.f_back.f_back + ): + frame = current_frame.f_back.f_back.f_back + filename = frame.f_code.co_filename + line_number = frame.f_lineno + else: + filename = "unknown" + line_number = 0 + + log_data = { + "timestamp": datetime.now().isoformat(), + "level": level.name, + "instance_id": self.instance_id, + "file": filename, + "line": line_number, + "trace_id": trace_id_var.get(), + "message": message, + } + + if level == LogLevel.EXCEPTION: + log_data["exception"] = traceback.format_exc() + + return log_data + + def _log(self, level: LogLevel, message: str) -> None: + if level >= self.min_level: + log_data = self._prepare_log_data(level, message) + + if self.log_format == LogFormat.JSON: + log_message = json.dumps(log_data, ensure_ascii=False) + else: + log_message = ( + f"{log_data['timestamp']} - {log_data['level']} - " + f"{log_data['instance_id']} - {log_data['trace_id']} - " + f"{log_data['file']}:{log_data['line']} - " + f"{log_data['message']}" + ) + if "exception" in log_data: + log_message += f"\nTraceback:\n{log_data['exception']}" + + self._write(log_message) + + def _write(self, message: str) -> None: + sys.stdout.write(message + "\n") + + def debug(self, message: str) -> None: + self._log(LogLevel.DEBUG, message) + + def info(self, message: str) -> None: + self._log(LogLevel.INFO, message) + + def warning(self, message: str) -> None: + self._log(LogLevel.WARNING, message) + + def error(self, message: str) -> None: + self._log(LogLevel.ERROR, message) + + def critical(self, message: str) -> None: + self._log(LogLevel.CRITICAL, message) + + def exception(self, message: str) -> None: + self._log(LogLevel.EXCEPTION, message) \ No newline at end of file diff --git a/src/infrastructure/messanger/__init__.py b/src/infrastructure/messanger/__init__.py new file mode 100644 index 0000000..0369f8a --- /dev/null +++ b/src/infrastructure/messanger/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.messanger.rabbit_client import RabbitClient \ No newline at end of file diff --git a/src/infrastructure/messanger/rabbit_client.py b/src/infrastructure/messanger/rabbit_client.py new file mode 100644 index 0000000..d18db3f --- /dev/null +++ b/src/infrastructure/messanger/rabbit_client.py @@ -0,0 +1,72 @@ +from typing import Any, Mapping +from faststream.rabbit import RabbitBroker +from src.application.contracts import IQueueMessanger +from src.infrastructure.config import settings + + +class RabbitClient(IQueueMessanger): + def __init__(self) -> None: + self._broker = RabbitBroker( + settings.RABBIT_URL, + ) + self._connected = False + + async def connect(self) -> None: + if self._connected: + return + await self._broker.connect() + self._connected = True + + async def close(self) -> None: + if not self._connected: + return + await self._broker.close() + self._connected = False + + async def _ensure_connected(self) -> None: + if not self._connected: + await self.connect() + + async def publish_to_queue( + self, + queue: str, + message: Any, + *, + persist: bool | None = None, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + await self._ensure_connected() + + await self._broker.publish( + message, + queue=queue, + persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, + headers=headers, + correlation_id=correlation_id, + message_id=message_id, + ) + + async def publish( + self, + message: Any, + *, + exchange: str, + routing_key: str, + persist: bool | None = None, + headers: Mapping[str, Any] | None = None, + correlation_id: str | None = None, + message_id: str | None = None, + ) -> None: + await self._ensure_connected() + + await self._broker.publish( + message, + exchange=exchange, + routing_key=routing_key, + persist=settings.RABBIT_PUBLISH_PERSIST if persist is None else persist, + headers=headers, + correlation_id=correlation_id, + message_id=message_id, + ) diff --git a/src/infrastructure/security/__init__.py b/src/infrastructure/security/__init__.py new file mode 100644 index 0000000..6dc434f --- /dev/null +++ b/src/infrastructure/security/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.security.jwt import JwtService +from src.infrastructure.security.csrf import CsrfService +from src.infrastructure.security.hash import HashService \ No newline at end of file diff --git a/src/infrastructure/security/csrf.py b/src/infrastructure/security/csrf.py new file mode 100644 index 0000000..28f8faf --- /dev/null +++ b/src/infrastructure/security/csrf.py @@ -0,0 +1,81 @@ +from __future__ import annotations +import secrets +from typing import Any, Optional, Mapping +from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature +from src.application.contracts import ICsrfService +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config.settings import settings + + +class CsrfService(ICsrfService): + COOKIE_NAME = "csrf_token" + HEADER_NAME = "X-CSRF-Token" + SALT = "csrf" + TTL_SECONDS = 3600 + + def __init__(self) -> None: + self._serializer = URLSafeTimedSerializer( + secret_key=settings.CSRF_SECRET_KEY, + salt=self.SALT, + ) + + @property + def cookie_name(self) -> str: + return self.COOKIE_NAME + + @property + def header_name(self) -> str: + return self.HEADER_NAME + + @property + def ttl_seconds(self) -> int: + return self.TTL_SECONDS + + def issue(self, subject: Optional[str] = None) -> str: + payload = { + "sub": subject, + "nonce": secrets.token_urlsafe(32), + } + return self._serializer.dumps(payload) + + def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]: + try: + data = self._serializer.loads(token, max_age=self.TTL_SECONDS) + except SignatureExpired: + raise ApplicationException( + status_code=403, + message="CSRF token expired", + ) + except BadSignature: + raise ApplicationException( + status_code=403, + message="CSRF token invalid", + ) + + if expected_subject is not None and data.get("sub") != expected_subject: + raise ApplicationException( + status_code=403, + message="CSRF token subject mismatch", + ) + + return data + + def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: + cookie_token = cookies.get(self.COOKIE_NAME) + header_token = headers.get(self.HEADER_NAME) + return cookie_token, header_token + + def verify_pair(self, cookie_token: Optional[str], header_token: Optional[str], expected_subject: Optional[str] = None) -> None: + if not cookie_token or not header_token: + raise ApplicationException( + status_code=403, + message="CSRF token missing", + ) + + if not secrets.compare_digest(cookie_token, header_token): + raise ApplicationException( + status_code=403, + message="CSRF token mismatch", + ) + + self.verify(cookie_token, expected_subject=expected_subject) diff --git a/src/infrastructure/security/hash.py b/src/infrastructure/security/hash.py new file mode 100644 index 0000000..94d92b8 --- /dev/null +++ b/src/infrastructure/security/hash.py @@ -0,0 +1,17 @@ +import bcrypt +from src.application.contracts import IHashService, ILogger + + +class HashService(IHashService): + + def __init__(self, logger: ILogger): + self._logger = logger + + async def hash(self, value: str) -> str: + hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt()) + self._logger.info(f'Hash value {hashed_value.decode()}') + return hashed_value.decode() + + async def verify(self, hashed_value: str, plain_value: str) -> bool: + self._logger.info(f'Hash value {hashed_value[:10]}') + return bcrypt.checkpw(plain_value.encode(), hashed_value.encode()) \ No newline at end of file diff --git a/src/infrastructure/security/jwt.py b/src/infrastructure/security/jwt.py new file mode 100644 index 0000000..d5bfeec --- /dev/null +++ b/src/infrastructure/security/jwt.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + +from jose import jwt, ExpiredSignatureError, JWTError + +from src.application.contracts import ILogger, IJwtService +from src.application.domain.dto import AccessTokenPayload +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config.settings import settings +from src.infrastructure.vault import JwtKeyStore + + +class JwtService(IJwtService): + def __init__(self, logger: ILogger, key_store: JwtKeyStore) -> None: + self._logger = logger + self._key_store = key_store + + @property + def _issuer(self) -> str | None: + return settings.ADMIN_JWT_ISSUER + + async def create_access_token(self, user_id: str, *, role: str) -> str: + now = datetime.now(timezone.utc) + exp = now + timedelta(seconds=int(settings.JWT_ACCESS_TTL_SECONDS)) + + payload: dict[str, object] = { + 'sub': user_id, + 'type': 'access', + 'role': role, + 'iat': int(now.timestamp()), + 'nbf': int(now.timestamp()), + 'exp': int(exp.timestamp()), + } + if self._issuer: + payload['iss'] = self._issuer + if settings.JWT_AUDIENCE: + payload['aud'] = settings.JWT_AUDIENCE + + try: + kid, private_pem = await self._key_store.get_signing_key() + token = jwt.encode(payload, private_pem, algorithm=settings.JWT_ALGORITHM, headers={'kid': kid}) + self._logger.info(f'Admin access token created admin_user_id={user_id} kid={kid}') + return token + except ApplicationException: + raise + except Exception as exception: + self._logger.error(f'JWT signing failed admin_user_id={user_id} error={exception}') + raise ApplicationException(status_code=500, message='JWT signing failed') + + async def decode_access_token(self, token: str) -> AccessTokenPayload: + payload = await self._decode_and_verify(token) + if payload.get('type') != 'access': + raise ApplicationException(status_code=401, message='Invalid token type') + try: + return AccessTokenPayload( + sub=str(payload['sub']), + type='access', + role=str(payload['role']) if payload.get('role') else None, + iat=int(payload['iat']), + nbf=int(payload['nbf']), + exp=int(payload['exp']), + iss=payload.get('iss'), + aud=payload.get('aud'), + ) + except KeyError as exception: + raise ApplicationException(status_code=401, message=f'Missing token claim: {exception}') + + async def _decode_and_verify(self, token: str) -> dict: + kid: str | None = None + try: + header = jwt.get_unverified_header(token) + kid = header.get('kid') + if not kid: + raise ApplicationException(status_code=401, message='Missing token header: kid') + if header.get('alg') != settings.JWT_ALGORITHM: + raise ApplicationException(status_code=401, message='Invalid token algorithm') + + public_pem = await self._key_store.get_public_key_for_kid(str(kid)) + if not public_pem: + raise ApplicationException(status_code=401, message='Unknown token kid') + + options = { + 'verify_signature': True, + 'verify_exp': True, + 'verify_nbf': True, + 'verify_iat': True, + 'verify_aud': bool(settings.JWT_AUDIENCE), + 'verify_iss': bool(self._issuer), + 'require_exp': True, + 'require_iat': True, + 'require_nbf': True, + 'require_sub': True, + 'leeway': 10, + } + + payload = jwt.decode( + token, + public_pem, + algorithms=[settings.JWT_ALGORITHM], + audience=settings.JWT_AUDIENCE or None, + issuer=self._issuer or None, + options=options, + ) + if 'type' not in payload: + raise ApplicationException(status_code=401, message='Missing token claim: type') + if payload.get('type') == 'access' and 'role' not in payload: + raise ApplicationException(status_code=401, message='Missing token claim: role') + return payload + except ExpiredSignatureError: + raise ApplicationException(status_code=401, message='Token expired') + except ApplicationException: + raise + except JWTError: + raise ApplicationException(status_code=401, message='Invalid token') + except Exception as exception: + self._logger.error(f'Unexpected JWT decode error kid={kid} error={exception}') + raise ApplicationException(status_code=500, message='JWT decode failed') diff --git a/src/infrastructure/storage/__init__.py b/src/infrastructure/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/storage/s3_documents_service.py b/src/infrastructure/storage/s3_documents_service.py new file mode 100644 index 0000000..bad3929 --- /dev/null +++ b/src/infrastructure/storage/s3_documents_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from aiobotocore.session import get_session + + +class S3DocumentsService: + def __init__( + self, + *, + bucket: str, + region: str, + access_key_id: str | None, + secret_access_key: str | None, + endpoint_url: str | None, + key_prefix: str = 'legal-docs', + presigned_ttl_seconds: int = 3600, + ): + self._bucket = bucket + self._region = region or 'us-east-1' + self._access_key_id = access_key_id + self._secret_access_key = secret_access_key + self._endpoint_url = endpoint_url.strip().rstrip('/') if endpoint_url and endpoint_url.strip() else None + self._key_prefix = key_prefix.strip('/') + self._presigned_ttl_seconds = presigned_ttl_seconds + + def build_object_key(self, organization_id: str, document_id: str, file_name: str) -> str: + safe_name = file_name.replace('/', '_').replace('\\', '_') + return f'{self._key_prefix}/{organization_id}/{document_id}/{safe_name}' + + def _client_kwargs(self) -> dict[str, object]: + kw: dict[str, object] = {'region_name': self._region} + if self._access_key_id: + kw['aws_access_key_id'] = self._access_key_id + if self._secret_access_key: + kw['aws_secret_access_key'] = self._secret_access_key + if self._endpoint_url: + kw['endpoint_url'] = self._endpoint_url + return kw + + async def upload_bytes(self, *, key: str, body: bytes, content_type: str) -> str: + session = get_session() + async with session.create_client('s3', **self._client_kwargs()) as client: + await client.put_object( + Bucket=self._bucket, + Key=key, + Body=body, + ContentType=content_type, + ) + return key + + async def generate_presigned_download_url(self, *, key: str, expires_in: int | None = None) -> str: + ttl = expires_in if expires_in is not None else self._presigned_ttl_seconds + session = get_session() + async with session.create_client('s3', **self._client_kwargs()) as client: + url = await client.generate_presigned_url( + ClientMethod='get_object', + Params={'Bucket': self._bucket, 'Key': key}, + ExpiresIn=ttl, + ) + return url + + async def delete_object(self, *, key: str) -> None: + session = get_session() + async with session.create_client('s3', **self._client_kwargs()) as client: + await client.delete_object(Bucket=self._bucket, Key=key) diff --git a/src/infrastructure/utils/__init__.py b/src/infrastructure/utils/__init__.py new file mode 100644 index 0000000..2f4158c --- /dev/null +++ b/src/infrastructure/utils/__init__.py @@ -0,0 +1 @@ +from src.infrastructure.utils.instance_id import generate_instance_id \ No newline at end of file diff --git a/src/infrastructure/utils/instance_id.py b/src/infrastructure/utils/instance_id.py new file mode 100644 index 0000000..49a571c --- /dev/null +++ b/src/infrastructure/utils/instance_id.py @@ -0,0 +1,14 @@ +from ulid import ULID + + +def generate_instance_id() -> str: + """ + Generate a process-wide instance id in ULID format. + + ULID is 26 chars (Crockford Base32) and lexicographically sortable by time. + """ + + + return str(ULID()) + + diff --git a/src/infrastructure/vault/__init__.py b/src/infrastructure/vault/__init__.py new file mode 100644 index 0000000..3300ec9 --- /dev/null +++ b/src/infrastructure/vault/__init__.py @@ -0,0 +1,3 @@ +from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret +from src.infrastructure.vault.keys import JwtKeyStore +from src.infrastructure.vault.scheduler import start_jwt_keys_scheduler \ No newline at end of file diff --git a/src/infrastructure/vault/keys.py b/src/infrastructure/vault/keys.py new file mode 100644 index 0000000..5a3dd81 --- /dev/null +++ b/src/infrastructure/vault/keys.py @@ -0,0 +1,118 @@ +from __future__ import annotations +import asyncio +from datetime import datetime, timezone +from src.application.domain.dto import JwtKeySet, JwtKeyPair +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.vault.utils import create_hvac_client_from_approle, read_kv2_secret + + + +class JwtKeyStore: + + _instance: "JwtKeyStore | None" = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + *, + vault_addr: str, + vault_role_id: str, + vault_secret_id: str, + vault_namespace: str | None, + mount_point: str, + kid_path: str = 'jwt/kid', + kids_prefix: str = 'jwt/kids', + timeout_seconds: int = 5, + ): + if getattr(self, '_initialized', False): + return + + self._vault_addr = vault_addr + self._vault_role_id = vault_role_id + self._vault_secret_id = vault_secret_id + self._vault_namespace = vault_namespace + self._timeout = timeout_seconds + + self._mount = mount_point + self._kid_path = kid_path + self._kids_prefix = kids_prefix + + self._lock = asyncio.Lock() + self._keyset: JwtKeySet | None = None + self._last_refresh_at: datetime | None = None + + self._initialized = True + + + @classmethod + def get_instance(cls) -> 'JwtKeyStore': + + if cls._instance is None: + raise ApplicationException(status_code=500, message='JwtKeyStore not initialized') + + return cls._instance + + def _read_keyset_sync(self) -> JwtKeySet: + client = create_hvac_client_from_approle( + url=self._vault_addr, + role_id=self._vault_role_id, + secret_id=self._vault_secret_id, + namespace=self._vault_namespace, + timeout=self._timeout, + ) + + kids = read_kv2_secret(client=client, mount_point=self._mount, path=self._kid_path) + active_kid = kids.get('active') + previous_kid = kids.get('previous') + + if not active_kid: + raise RuntimeError('Vault jwt/kid secret missing active') + + active_pair = self._read_keypair_sync(client, active_kid) + + prev_pair = None + if previous_kid and previous_kid != active_kid: + prev_pair = self._read_keypair_sync(client, previous_kid) + + return JwtKeySet(active=active_pair, previous=prev_pair) + + def _read_keypair_sync(self, client, kid: str) -> JwtKeyPair: + data = read_kv2_secret( + client=client, + mount_point=self._mount, + path=f'{self._kids_prefix}/{kid}', + ) + priv = data.get('private_key') + pub = data.get('public_key') + if not priv or not pub: + raise RuntimeError(f'Vault jwt/kids/{kid} missing private_key/public_key') + return JwtKeyPair(kid=kid, private_key_pem=priv, public_key_pem=pub) + + + async def refresh(self) -> JwtKeySet: + keyset = await asyncio.to_thread(self._read_keyset_sync) + async with self._lock: + self._keyset = keyset + self._last_refresh_at = datetime.now(timezone.utc) + return keyset + + async def get_signing_key(self) -> tuple[str, str]: + ks = await self._get_or_refresh() + return ks.active.kid, ks.active.private_key_pem + + async def get_public_key_for_kid(self, kid: str) -> str | None: + ks = await self._get_or_refresh() + return ks.public_keys_by_kid().get(kid) + + async def last_refresh_at(self) -> datetime | None: + async with self._lock: + return self._last_refresh_at + + async def _get_or_refresh(self) -> JwtKeySet: + async with self._lock: + ks = self._keyset + return ks if ks else await self.refresh() \ No newline at end of file diff --git a/src/infrastructure/vault/scheduler.py b/src/infrastructure/vault/scheduler.py new file mode 100644 index 0000000..0a46589 --- /dev/null +++ b/src/infrastructure/vault/scheduler.py @@ -0,0 +1,24 @@ +from __future__ import annotations +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from src.infrastructure.vault import JwtKeyStore + + +logger = logging.getLogger(__name__) + + +def start_jwt_keys_scheduler(store: JwtKeyStore, *, refresh_seconds: int = 3600) -> AsyncIOScheduler: + scheduler = AsyncIOScheduler() + scheduler.add_job( + store.refresh, + trigger=IntervalTrigger(seconds=refresh_seconds), + id="jwt_keys_refresh", + replace_existing=True, + max_instances=1, + coalesce=True, + misfire_grace_time=60, + ) + scheduler.start() + logger.info("JWT keys scheduler started (interval=%s seconds)", refresh_seconds) + return scheduler \ No newline at end of file diff --git a/src/infrastructure/vault/utils.py b/src/infrastructure/vault/utils.py new file mode 100644 index 0000000..c0dee85 --- /dev/null +++ b/src/infrastructure/vault/utils.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import hvac + + +def create_hvac_client_from_approle( + *, + url: str, + role_id: str, + secret_id: str, + namespace: str | None = None, + timeout: int = 5, +) -> hvac.Client: + kwargs: dict = {'url': url, 'timeout': timeout} + if namespace: + kwargs['namespace'] = namespace + client = hvac.Client(**kwargs) + client.auth.approle.login(role_id=role_id, secret_id=secret_id) + if not client.is_authenticated(): + raise RuntimeError( + 'Vault AppRole authentication failed. Check VAULT_ADDR, VAULT_ROLE_ID, VAULT_SECRET_ID' + ) + return client + + +def read_kv2_secret(*, client: hvac.Client, mount_point: str, path: str) -> dict: + secret = client.secrets.kv.v2.read_secret_version( + mount_point=mount_point, + path=path, + ) + return secret["data"]["data"] \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..ffa1f97 --- /dev/null +++ b/src/main.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +import secrets +from typing import AsyncGenerator + +from fastapi import Depends, FastAPI, status +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi +from fastapi.responses import HTMLResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.cors import CORSMiddleware + +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.config.settings import get_settings +from src.infrastructure.crypto.wallet_crypto import load_master_key_from_vault +from src.infrastructure.vault import JwtKeyStore, start_jwt_keys_scheduler +from src.infrastructure.utils import generate_instance_id +from src.infrastructure.logger import logger +from src.infrastructure.config import settings +from src.presentation.handler import application_exception_handler +from src.presentation.handler import unhandled_exception_handler +from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware +from src.presentation.routing import v1_router +from src.presentation.schemas import ErrorResponse + +security = HTTPBasic() + +ERROR_RESPONSES: dict[int, str] = { + status.HTTP_400_BAD_REQUEST: 'Bad Request', + status.HTTP_401_UNAUTHORIZED: 'Unauthorized', + status.HTTP_403_FORBIDDEN: 'Forbidden', + status.HTTP_404_NOT_FOUND: 'Not Found', + status.HTTP_409_CONFLICT: 'Conflict', + status.HTTP_429_TOO_MANY_REQUESTS: 'Too Many Requests', + status.HTTP_500_INTERNAL_SERVER_ERROR: 'Internal Server Error', + status.HTTP_503_SERVICE_UNAVAILABLE: 'Service Unavailable', +} + + +def custom_openapi() -> dict: + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + license_info=app.license_info, + ) + components = openapi_schema.setdefault('components', {}) + schemas = components.setdefault('schemas', {}) + schemas['ErrorResponse'] = ErrorResponse.model_json_schema() + + for path_item in openapi_schema.get('paths', {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict): + continue + responses = operation.setdefault('responses', {}) + for status_code, description in ERROR_RESPONSES.items(): + responses.setdefault( + str(status_code), + { + 'description': description, + 'content': { + 'application/json': { + 'schema': {'$ref': '#/components/schemas/ErrorResponse'}, + }, + }, + }, + ) + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +async def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> HTTPBasicCredentials: + user_ok = secrets.compare_digest(credentials.username, settings.DOCS_USERNAME) + pass_ok = secrets.compare_digest(credentials.password, settings.DOCS_PASSWORD) + if not (user_ok and pass_ok): + raise ApplicationException( + status_code=status.HTTP_401_UNAUTHORIZED, + message='Unauthorized', + headers={'WWW-Authenticate': 'Basic'}, + ) + return credentials + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + instance_id = generate_instance_id() + logger.set_instance_id(instance_id) + logger.info(f'Admin service instance started with id {instance_id}') + + jwt_store = JwtKeyStore( + vault_addr=settings.VAULT_ADDR, + vault_role_id=settings.VAULT_ROLE_ID, + vault_secret_id=settings.VAULT_SECRET_ID, + vault_namespace=settings.VAULT_NAMESPACE, + mount_point=settings.VAULT_MOUNT_POINT, + kid_path=settings.VAULT_JWT_KID_PATH, + kids_prefix=settings.VAULT_JWT_KIDS_PREFIX, + ) + await jwt_store.refresh() + jwt_scheduler = start_jwt_keys_scheduler(jwt_store, refresh_seconds=settings.JWT_KEYS_REFRESH_SECONDS) + app.state.jwt_key_store = jwt_store + app.state.jwt_keys_scheduler = jwt_scheduler + + load_master_key_from_vault( + vault_addr=settings.VAULT_ADDR, + vault_role_id=settings.VAULT_ROLE_ID, + vault_secret_id=settings.VAULT_SECRET_ID, + vault_namespace=settings.VAULT_NAMESPACE, + mount_point=settings.VAULT_MOUNT_POINT, + path=settings.VAULT_CRYPTO_MASTER_KEY_PATH, + ) + logger.info('Crypto master key loaded from Vault') + yield + logger.info('Shutting down...') + sched = getattr(app.state, 'jwt_keys_scheduler', None) + if sched: + sched.shutdown(wait=False) + logger.info('Admin service stopped') + + +app: FastAPI = FastAPI( + redoc_url=None, + docs_url=None, + lifespan=lifespan, + title='Bitforce. Admin Service', + version='1.0.0', + description='Admin API for legal entities, wallets, documents, and B2B purchase requests', + license_info={'name': 'MIT', 'url': 'https://opensource.org/licenses/MIT'}, +) + +app.add_exception_handler(ApplicationException, application_exception_handler) +app.add_exception_handler(Exception, unhandled_exception_handler) +app.include_router(v1_router) + +app.add_middleware(TraceIDMiddleware, logger=logger) +app.add_middleware( + SecurityHeadersMiddleware, + hsts=True, + hsts_preload=False, + frame_options='DENY', + referrer_policy='strict-origin-when-cross-origin', + content_security_policy="default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'", +) +app.add_middleware( + CORSMiddleware, + allow_origin_regex=settings.CORS_ALLOW_ORIGIN_REGEX, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + + +@app.get('/docs', include_in_schema=False) +async def custom_swagger_ui_html(_credentials: HTTPBasicCredentials = Depends(verify_credentials)) -> HTMLResponse: + return get_swagger_ui_html( + openapi_url=getattr(app, 'openapi_url', '/openapi.json'), + title=getattr(app, 'title', 'FastAPI') + ' - Swagger UI', + oauth2_redirect_url=getattr(app, 'swagger_ui_oauth2_redirect_url', None), + swagger_js_url='https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js', + swagger_css_url='https://unpkg.com/swagger-ui-dist@5/swagger-ui.css', + ) + + +@app.get('/redoc', include_in_schema=False) +async def custom_redoc_html(_credentials: HTTPBasicCredentials = Depends(verify_credentials)) -> HTMLResponse: + return get_redoc_html( + openapi_url=getattr(app, 'openapi_url', '/openapi.json'), + title=getattr(app, 'title', 'FastAPI') + ' - ReDoc', + redoc_js_url='https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js', + ) + + +@app.post('/ping') +async def ping() -> dict[str, str]: + return {'message': 'pong', 'status': 'ok'} + + +app.openapi = custom_openapi diff --git a/src/presentation/decorators/__init__.py b/src/presentation/decorators/__init__.py new file mode 100644 index 0000000..be6a33b --- /dev/null +++ b/src/presentation/decorators/__init__.py @@ -0,0 +1 @@ +from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role diff --git a/src/presentation/decorators/admin_auth.py b/src/presentation/decorators/admin_auth.py new file mode 100644 index 0000000..e7d12aa --- /dev/null +++ b/src/presentation/decorators/admin_auth.py @@ -0,0 +1,47 @@ +from fastapi import Depends, Request +from fastapi.security.utils import get_authorization_scheme_param + +from src.application.contracts import IJwtService +from src.application.domain.dto import AdminAuthContext +from src.application.domain.exceptions import ApplicationException +from src.presentation.dependencies.security import get_jwt_service + + +def _extract_bearer_token(request: Request) -> str | None: + auth = request.headers.get('Authorization') + if not auth: + return None + scheme, param = get_authorization_scheme_param(auth) + if scheme.lower() == 'bearer' and param: + return param + return None + + +async def require_admin_access( + request: Request, + jwt_service: IJwtService = Depends(get_jwt_service), +) -> AdminAuthContext: + token = _extract_bearer_token(request) + if not token: + raise ApplicationException(status_code=401, message='Authorization Bearer token required') + + payload = await jwt_service.decode_access_token(token) + if payload.type != 'access': + raise ApplicationException(status_code=401, message='Invalid token type') + + role = payload.role + if not role: + raise ApplicationException(status_code=401, message='Token missing role') + + return AdminAuthContext(admin_user_id=payload.sub, role=role) + + +def require_admin_role(*allowed_roles: str): + allowed = set(allowed_roles) + + async def dependency(auth: AdminAuthContext = Depends(require_admin_access)) -> AdminAuthContext: + if auth.role not in allowed: + raise ApplicationException(status_code=403, message='Insufficient permissions') + return auth + + return dependency diff --git a/src/presentation/decorators/csrf.py b/src/presentation/decorators/csrf.py new file mode 100644 index 0000000..768e69e --- /dev/null +++ b/src/presentation/decorators/csrf.py @@ -0,0 +1,61 @@ +from __future__ import annotations +import inspect +from functools import wraps +from typing import Callable, Awaitable, Any, Optional, Annotated +from fastapi import Request, Header +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.security import CsrfService + + +def csrf_protect( + expected_subject_getter: Optional[Callable[[Request], Optional[str]]] = None, +): + def decorator(func: Callable[..., Awaitable[Any]]): + sig = inspect.signature(func) + params = list(sig.parameters.values()) + + has_request = any(p.annotation is Request or p.name == 'request' for p in params) + if not has_request: + raise RuntimeError('csrf_protect requires endpoint to accept `request: Request`') + + has_header = any(p.name == 'x_csrf_token' for p in params) + if not has_header: + params.append( + inspect.Parameter( + name='x_csrf_token', + kind=inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=Annotated[str | None, Header(alias='X-CSRF-Token')], + ) + ) + + @wraps(func) + async def wrapper(*args, **kwargs): + request: Request | None = kwargs.get('request') + if request is None: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if request is None: + raise ApplicationException( + status_code=500, + message='Request is required for CSRF protection', + ) + + csrf = CsrfService() + + cookie_token, _ = csrf.extract(request.cookies, request.headers) + header_token = kwargs.get('x_csrf_token') + + expected_subject = expected_subject_getter(request) if expected_subject_getter else None + csrf.verify_pair(cookie_token, header_token, expected_subject) + + kwargs.pop('x_csrf_token', None) + return await func(*args, **kwargs) + + wrapper.__signature__ = sig.replace(parameters=params) + return wrapper + + return decorator diff --git a/src/presentation/decorators/rate_limit.py b/src/presentation/decorators/rate_limit.py new file mode 100644 index 0000000..6ff0094 --- /dev/null +++ b/src/presentation/decorators/rate_limit.py @@ -0,0 +1,171 @@ +from __future__ import annotations +import functools +import inspect +import hashlib +from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, runtime_checkable +from fastapi import Request +from redis.asyncio.client import Redis +from src.application.contracts import ILogger +from src.application.domain.exceptions import ApplicationException +from src.infrastructure.logger import get_logger +from src.presentation.dependencies import get_redis + + +def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request: + req = kwargs.get('request') + if isinstance(req, Request): + return req + for a in args: + if isinstance(a, Request): + return a + raise RuntimeError('rate_limit decorator requires fastapi.Request argument') + + +def _client_ip(request: Request) -> str: + xff = request.headers.get('x-forwarded-for') + if xff: + return xff.split(',')[0].strip() + if request.client: + return request.client.host + return 'unknown' + + +_LUA_INCR_EXPIRE_TTL = ''' +local key = KEYS[1] +local window = tonumber(ARGV[1]) + +local current = redis.call('INCR', key) +if current == 1 then + redis.call('EXPIRE', key, window) +end + +local ttl = redis.call('TTL', key) +return { current, ttl } +''' + + +Scope = Literal['ip', 'device', 'user', 'key'] + + +@runtime_checkable +class KeyBuilder1(Protocol): + def __call__(self, request: Request) -> str: ... + + +@runtime_checkable +class KeyBuilder3(Protocol): + def __call__(self, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: ... + + +KeyBuilder = KeyBuilder1 | KeyBuilder3 + + +def _call_key_builder(builder: KeyBuilder, request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + try: + sig = inspect.signature(builder) + if len(sig.parameters) >= 3: + return builder(request, args, kwargs) + return builder(request) + except Exception as e: + try: + return builder(request, args, kwargs) + except Exception: + raise e + +def _email_rl_key(request: Request, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + + body = kwargs.get('body') + if body is None and args: + for a in args: + if hasattr(a, 'email'): + body = a + break + + email = (getattr(body, 'email', '') or '').strip().lower() + if not email: + email = _client_ip(request) + + digest = hashlib.sha256(email.encode('utf-8')).hexdigest()[:24] + return f'email:{digest}' + +def rate_limit( + *, + limit: int, + window_seconds: int, + scope: Scope = 'ip', + key_prefix: str = 'rl', + key_builder: Optional[KeyBuilder] = None, + fail_open: bool = True, +) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: + + if limit <= 0: + raise ValueError('rate_limit: limit must be > 0') + if window_seconds <= 0: + raise ValueError('rate_limit: window_seconds must be > 0') + if scope == 'key' and not key_builder: + raise ValueError('rate_limit: scope="key" requires key_builder') + + def decorator(func: Callable[..., Awaitable[Any]]): + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any): + request = _find_request(args, kwargs) + logger: ILogger = get_logger() + + if scope == 'ip': + ident = _client_ip(request) + elif scope == 'device': + ident = request.cookies.get('device_id') or _client_ip(request) + elif scope == 'user': + user = getattr(request.state, 'user', None) + user_id = getattr(user, 'id', None) if user else None + ident = str(user_id) if user_id else _client_ip(request) + else: + try: + ident = _call_key_builder(key_builder, request, args, kwargs) # type: ignore[arg-type] + except Exception as e: + logger.error(f'RateLimit key_builder failed error={str(e)}') + raise ApplicationException(500, 'Rate limiter key_builder failed') + + route = request.url.path + method = request.method + redis_key = f'{key_prefix}:{scope}:{method}:{route}:{ident}' + + logger.debug(f'RateLimit check key={redis_key} limit={limit} window={window_seconds}') + + try: + redis: Redis = get_redis(request) + + result = await redis.eval( + _LUA_INCR_EXPIRE_TTL, + 1, + redis_key, + str(window_seconds), + ) + + count = int(result[0]) + ttl_raw = int(result[1]) if result and len(result) > 1 else window_seconds + ttl = window_seconds if ttl_raw < 0 else ttl_raw + + except Exception as e: + logger.error(f'RateLimit redis failure key={redis_key} error={str(e)}') + + if fail_open: + logger.warning(f'RateLimit fail-open activated key={redis_key}') + return await func(*args, **kwargs) + + raise ApplicationException(503, 'Rate limiter unavailable') + + if count > limit: + retry_after = max(ttl, 0) + logger.warning(f'RateLimit exceeded key={redis_key} count={count} limit={limit} retry_after={retry_after}') + raise ApplicationException( + status_code=429, + message='Too Many Requests', + headers={'Retry-After': str(retry_after)}, + ) + + logger.debug(f'RateLimit passed key={redis_key} count={count}') + return await func(*args, **kwargs) + + return wrapper + return decorator \ No newline at end of file diff --git a/src/presentation/dependencies/__init__.py b/src/presentation/dependencies/__init__.py new file mode 100644 index 0000000..069d60f --- /dev/null +++ b/src/presentation/dependencies/__init__.py @@ -0,0 +1,6 @@ +from src.presentation.dependencies.commands import ( + get_admin_login_command, + get_admin_logout_command, + get_admin_jwt_refresh_command, + get_admin_me_command, +) diff --git a/src/presentation/dependencies/cache.py b/src/presentation/dependencies/cache.py new file mode 100644 index 0000000..fb4fc7a --- /dev/null +++ b/src/presentation/dependencies/cache.py @@ -0,0 +1,12 @@ +from fastapi import Depends, Request +from redis.asyncio.client import Redis +from src.application.contracts import ICache +from src.infrastructure.cache import KeydbCache + + +def get_redis(request: Request) -> Redis: + return request.app.state.redis + + +def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache: + return KeydbCache(redis_client) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py new file mode 100644 index 0000000..9b08d80 --- /dev/null +++ b/src/presentation/dependencies/commands.py @@ -0,0 +1,145 @@ +from functools import lru_cache + +from fastapi import Depends + +from src.application.abstractions import IUnitOfWork +from src.application.commands import ( + AdminLoginCommand, + GetAdminMeCommand, + CreateOrganizationCommand, + CreateOrganizationWalletsCommand, + GetOrganizationCommand, + GetPurchaseRequestCommand, + ListOrganizationsCommand, + GetOrganizationDocumentCommand, + ListOrganizationDocumentsCommand, + ListPurchaseRequestsCommand, + SetPurchaseRequestQuoteCommand, + UpdateOrganizationCommand, + UpdatePurchaseRequestStatusCommand, + UploadOrganizationDocumentCommand, +) +from src.application.contracts import IHashService, IJwtService, ILogger +from src.infrastructure.config import settings +from src.infrastructure.storage.s3_documents_service import S3DocumentsService +from src.presentation.dependencies.logger import get_logger +from src.presentation.dependencies.security import get_hash_service, get_jwt_service +from src.presentation.dependencies.unit_of_work import get_unit_of_work + + +@lru_cache(maxsize=1) +def _s3_documents_service() -> S3DocumentsService: + return S3DocumentsService( + bucket=settings.LEGAL_DOCS_S3_BUCKET, + region=settings.LEGAL_DOCS_S3_REGION, + access_key_id=settings.LEGAL_DOCS_S3_ACCESS_KEY_ID or None, + secret_access_key=settings.LEGAL_DOCS_S3_SECRET_ACCESS_KEY or None, + endpoint_url=settings.LEGAL_DOCS_S3_ENDPOINT_URL or None, + key_prefix=settings.LEGAL_DOCS_S3_KEY_PREFIX, + presigned_ttl_seconds=settings.LEGAL_DOCS_S3_PRESIGNED_TTL_SECONDS, + ) + + +def get_s3_documents_service() -> S3DocumentsService: + return _s3_documents_service() + + +def get_admin_login_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + hash_service: IHashService = Depends(get_hash_service), + jwt_service: IJwtService = Depends(get_jwt_service), + logger: ILogger = Depends(get_logger), +) -> AdminLoginCommand: + return AdminLoginCommand(uow, hash_service, jwt_service, logger) + + +def get_admin_me_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> GetAdminMeCommand: + return GetAdminMeCommand(uow, logger) + + +def get_create_organization_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + hash_service: IHashService = Depends(get_hash_service), + logger: ILogger = Depends(get_logger), +) -> CreateOrganizationCommand: + return CreateOrganizationCommand(uow, hash_service, logger) + + +def get_create_organization_wallets_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> CreateOrganizationWalletsCommand: + return CreateOrganizationWalletsCommand(uow, logger) + + +def get_upload_organization_document_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> UploadOrganizationDocumentCommand: + return UploadOrganizationDocumentCommand(uow, get_s3_documents_service(), logger) + + +def get_list_organizations_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> ListOrganizationsCommand: + return ListOrganizationsCommand(uow, logger) + + +def get_get_organization_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> GetOrganizationCommand: + return GetOrganizationCommand(uow, logger) + + +def get_update_organization_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> UpdateOrganizationCommand: + return UpdateOrganizationCommand(uow, logger) + + +def get_list_organization_documents_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> ListOrganizationDocumentsCommand: + return ListOrganizationDocumentsCommand(uow, logger) + + +def get_get_organization_document_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> GetOrganizationDocumentCommand: + return GetOrganizationDocumentCommand(uow, logger) + + +def get_list_purchase_requests_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> ListPurchaseRequestsCommand: + return ListPurchaseRequestsCommand(uow, logger) + + +def get_get_purchase_request_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> GetPurchaseRequestCommand: + return GetPurchaseRequestCommand(uow, logger) + + +def get_update_purchase_request_status_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> UpdatePurchaseRequestStatusCommand: + return UpdatePurchaseRequestStatusCommand(uow, logger) + + +def get_set_purchase_request_quote_command( + uow: IUnitOfWork = Depends(get_unit_of_work), + logger: ILogger = Depends(get_logger), +) -> SetPurchaseRequestQuoteCommand: + return SetPurchaseRequestQuoteCommand(uow, logger) diff --git a/src/presentation/dependencies/logger.py b/src/presentation/dependencies/logger.py new file mode 100644 index 0000000..3c95f84 --- /dev/null +++ b/src/presentation/dependencies/logger.py @@ -0,0 +1,7 @@ +from functools import lru_cache +from src.application.contracts import ILogger +from src.infrastructure.logger import logger + +@lru_cache +def get_logger() -> ILogger: + return logger \ No newline at end of file diff --git a/src/presentation/dependencies/queue_messanger.py b/src/presentation/dependencies/queue_messanger.py new file mode 100644 index 0000000..87a2d3a --- /dev/null +++ b/src/presentation/dependencies/queue_messanger.py @@ -0,0 +1,8 @@ +from functools import lru_cache +from src.application.contracts import IQueueMessanger +from src.infrastructure.messanger import RabbitClient + + +@lru_cache(maxsize=1) +def get_rabbit() -> IQueueMessanger: + return RabbitClient() diff --git a/src/presentation/dependencies/security.py b/src/presentation/dependencies/security.py new file mode 100644 index 0000000..62e6664 --- /dev/null +++ b/src/presentation/dependencies/security.py @@ -0,0 +1,25 @@ +from functools import lru_cache +from fastapi import Depends +from src.application.contracts import IHashService, IJwtService, ILogger +from src.infrastructure.security import HashService, JwtService +from src.infrastructure.vault import JwtKeyStore +from src.presentation.dependencies.logger import get_logger + + +@lru_cache(maxsize=1) +def _hash_service(logger: ILogger) -> IHashService: + return HashService(logger=logger) + + +def get_hash_service(logger: ILogger = Depends(get_logger)) -> IHashService: + return _hash_service(logger) + + +@lru_cache(maxsize=1) +def _jwt_service(logger: ILogger) -> IJwtService: + key_store = JwtKeyStore.get_instance() + return JwtService(logger=logger, key_store=key_store) + + +def get_jwt_service(logger: ILogger = Depends(get_logger)) -> IJwtService: + return _jwt_service(logger) \ No newline at end of file diff --git a/src/presentation/dependencies/unit_of_work.py b/src/presentation/dependencies/unit_of_work.py new file mode 100644 index 0000000..5629bd4 --- /dev/null +++ b/src/presentation/dependencies/unit_of_work.py @@ -0,0 +1,10 @@ +from fastapi import Depends +from src.application.abstractions import IUnitOfWork +from src.application.contracts import ILogger +from src.infrastructure.database import UnitOfWork +from src.infrastructure.database.context import async_session_maker +from src.infrastructure.logger import get_logger + + +def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork: + return UnitOfWork(session_factory=async_session_maker, logger=logger) \ No newline at end of file diff --git a/src/presentation/handler/__init__.py b/src/presentation/handler/__init__.py new file mode 100644 index 0000000..b844c75 --- /dev/null +++ b/src/presentation/handler/__init__.py @@ -0,0 +1,2 @@ +from src.presentation.handler.application_exception_handler import application_exception_handler +from src.presentation.handler.unhandled_exception_handler import unhandled_exception_handler diff --git a/src/presentation/handler/application_exception_handler.py b/src/presentation/handler/application_exception_handler.py new file mode 100644 index 0000000..d6ce09e --- /dev/null +++ b/src/presentation/handler/application_exception_handler.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.responses import ORJSONResponse +from src.application.domain.exceptions import ApplicationException + + +async def application_exception_handler(_request: Request, exc: ApplicationException) -> ORJSONResponse: + detail = exc.message + if 500 <= exc.status_code: + detail = 'Internal Server Error' + + return ORJSONResponse( + status_code=exc.status_code, + content={'detail': detail}, + headers=dict(exc.headers) if exc.headers else None, + ) diff --git a/src/presentation/handler/unhandled_exception_handler.py b/src/presentation/handler/unhandled_exception_handler.py new file mode 100644 index 0000000..1249344 --- /dev/null +++ b/src/presentation/handler/unhandled_exception_handler.py @@ -0,0 +1,12 @@ +from fastapi import Request +from fastapi.responses import ORJSONResponse +from starlette import status +from src.infrastructure.logger import logger + + +async def unhandled_exception_handler(_request: Request, exc: Exception) -> ORJSONResponse: + logger.exception(f'Unhandled exception: {type(exc).__name__}') + return ORJSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={'detail': 'Internal Server Error'}, + ) diff --git a/src/presentation/middleware/__init__.py b/src/presentation/middleware/__init__.py new file mode 100644 index 0000000..50faccd --- /dev/null +++ b/src/presentation/middleware/__init__.py @@ -0,0 +1,2 @@ +from src.presentation.middleware.trace_id import TraceIDMiddleware +from src.presentation.middleware.security_headers import SecurityHeadersMiddleware \ No newline at end of file diff --git a/src/presentation/middleware/security_headers.py b/src/presentation/middleware/security_headers.py new file mode 100644 index 0000000..7cd0387 --- /dev/null +++ b/src/presentation/middleware/security_headers.py @@ -0,0 +1,51 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + hsts: bool = True, + hsts_max_age: int = 31536000, # 1 год + hsts_include_subdomains: bool = True, + hsts_preload: bool = False, + frame_options: str = 'DENY', # или 'SAMEORIGIN' + referrer_policy: str = 'strict-origin-when-cross-origin', + content_security_policy: str | None = None, + ): + super().__init__(app) + self.hsts = hsts + self.hsts_max_age = hsts_max_age + self.hsts_include_subdomains = hsts_include_subdomains + self.hsts_preload = hsts_preload + self.frame_options = frame_options + self.referrer_policy = referrer_policy + self.csp = content_security_policy + + async def dispatch(self, request: Request, call_next) -> Response: + response: Response = await call_next(request) + + if request.url.path in ('/docs', '/redoc', '/openapi.json'): + return response + + if self.hsts and request.url.scheme == 'https': + hsts = f'max-age={self.hsts_max_age}' + if self.hsts_include_subdomains: + hsts += '; includeSubDomains' + if self.hsts_preload: + hsts += '; preload' + response.headers['Strict-Transport-Security'] = hsts + + response.headers['X-Content-Type-Options'] = 'nosniff' + + response.headers['X-Frame-Options'] = self.frame_options + + response.headers['Referrer-Policy'] = self.referrer_policy + + if self.csp: + response.headers['Content-Security-Policy'] = self.csp + + return response diff --git a/src/presentation/middleware/trace_id.py b/src/presentation/middleware/trace_id.py new file mode 100644 index 0000000..40307ee --- /dev/null +++ b/src/presentation/middleware/trace_id.py @@ -0,0 +1,69 @@ +from __future__ import annotations +from typing import Optional +from contextvars import Token +from starlette.requests import Request +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from ulid import ULID +from src.application.contracts import ILogger +from src.infrastructure.config import settings +from src.infrastructure.context_vars import trace_id_var + + +class TraceIDMiddleware: + def __init__( + self, + app: ASGIApp, + logger: ILogger, + response_header_name: str = "X-Trace-ID", + attach_response_header: bool = True, + ) -> None: + self.app = app + self.logger = logger + self.response_header_name = response_header_name + self.attach_response_header = attach_response_header + + def _is_excluded(self, path: str) -> bool: + return any(path == p or path.startswith(p.rstrip("/") + "/") for p in settings.EXCLUDED_PATHS) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope) + + if self._is_excluded(request.url.path): + await self.app(scope, receive, send) + return + + trace_id = request.headers.get("X-Trace-ID") or request.headers.get("X-Request-ID") + if not trace_id: + trace_id = str(ULID()) + + request.state.trace_id = trace_id + + token: Token = trace_id_var.set(trace_id) + + self.logger.debug(f"Request started: {request.method} {request.url} - TraceID: {trace_id}") + + status_code_holder: dict[str, Optional[int]] = {"status": None} + + async def send_wrapper(message: Message) -> None: + if message["type"] == "http.response.start": + status_code_holder["status"] = int(message["status"]) + + if self.attach_response_header: + headers = list(message.get("headers", [])) + headers.append((self.response_header_name.lower().encode(), trace_id.encode())) + message["headers"] = headers + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + status = status_code_holder["status"] + status_part = f"{status}" if status is not None else "unknown" + self.logger.debug( + f"Request finished: {request.method} {request.url} - TraceID: {trace_id} - Status: {status_part}" + ) + trace_id_var.reset(token) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py new file mode 100644 index 0000000..b6179d2 --- /dev/null +++ b/src/presentation/routing/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from src.presentation.routing.auth import auth_router +from src.presentation.routing.documents import documents_router +from src.presentation.routing.organizations import organizations_router +from src.presentation.routing.purchase_requests import purchase_requests_router + +v1_router = APIRouter(prefix='/v1') +v1_router.include_router(auth_router) +v1_router.include_router(organizations_router) +v1_router.include_router(documents_router) +v1_router.include_router(purchase_requests_router) diff --git a/src/presentation/routing/auth.py b/src/presentation/routing/auth.py new file mode 100644 index 0000000..49ac9f6 --- /dev/null +++ b/src/presentation/routing/auth.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends, status +from fastapi.responses import ORJSONResponse + +from src.application.commands import AdminLoginCommand, GetAdminMeCommand +from src.application.domain.dto import AdminAuthContext +from src.presentation.decorators.admin_auth import require_admin_access +from src.presentation.dependencies.commands import get_admin_login_command, get_admin_me_command +from src.presentation.schemas.admin_auth import AdminLoginRequest, AdminLoginResponse, AdminMeResponse + +auth_router = APIRouter(prefix='/auth', tags=['auth']) + + +@auth_router.post('/login', response_model=AdminLoginResponse, status_code=status.HTTP_200_OK) +async def admin_login( + body: AdminLoginRequest, + command: AdminLoginCommand = Depends(get_admin_login_command), +): + dto = await command(email=str(body.email), password=body.password) + return AdminLoginResponse( + access_token=dto.access_token, + id=dto.id, + email=dto.email, + first_name=dto.first_name, + last_name=dto.last_name, + role=dto.role, + ) + + +@auth_router.post('/logout', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) +async def admin_logout(): + """Клиент удаляет access_token локально. Сервер stateless.""" + return {'ok': True} + + +@auth_router.get('/me', response_model=AdminMeResponse) +async def admin_me( + auth: AdminAuthContext = Depends(require_admin_access), + command: GetAdminMeCommand = Depends(get_admin_me_command), +): + admin = await command(auth.admin_user_id) + return AdminMeResponse( + id=admin.id, + email=admin.email, + first_name=admin.first_name, + last_name=admin.last_name, + role=admin.role, + ) diff --git a/src/presentation/routing/documents.py b/src/presentation/routing/documents.py new file mode 100644 index 0000000..4a93c95 --- /dev/null +++ b/src/presentation/routing/documents.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, File, Form, UploadFile, status + +from src.application.commands import ( + GetOrganizationDocumentCommand, + ListOrganizationDocumentsCommand, + UploadOrganizationDocumentCommand, +) +from src.application.domain.dto import AdminAuthContext +from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role +from src.presentation.dependencies.commands import ( + get_get_organization_document_command, + get_list_organization_documents_command, + get_s3_documents_service, + get_upload_organization_document_command, +) +from src.infrastructure.storage.s3_documents_service import S3DocumentsService +from src.presentation.schemas.mappers import document_to_response +from src.presentation.schemas.organization import DocumentResponse + +documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents']) + + +@documents_router.get('', response_model=list[DocumentResponse]) +async def list_documents( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: ListOrganizationDocumentsCommand = Depends(get_list_organization_documents_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + docs = await command(organization_id) + result: list[DocumentResponse] = [] + for doc in docs: + url = await s3.generate_presigned_download_url(key=doc.s3_key) + result.append(document_to_response(doc, download_url=url)) + return result + + +@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_document( + organization_id: str, + document_type: str = Form(...), + file: UploadFile = File(...), + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + body = await file.read() + saved = await command( + organization_id=organization_id, + admin_user_id=auth.admin_user_id, + document_type=document_type, + file_name=file.filename or 'document', + content_type=file.content_type or 'application/octet-stream', + body=body, + ) + url = await s3.generate_presigned_download_url(key=saved.s3_key) + return document_to_response(saved, download_url=url) + + +@documents_router.get('/{document_id}', response_model=DocumentResponse) +async def get_document( + organization_id: str, + document_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command), + s3: S3DocumentsService = Depends(get_s3_documents_service), +): + doc = await command(organization_id, document_id) + url = await s3.generate_presigned_download_url(key=doc.s3_key) + return document_to_response(doc, download_url=url) diff --git a/src/presentation/routing/jwt.py b/src/presentation/routing/jwt.py new file mode 100644 index 0000000..b085d94 --- /dev/null +++ b/src/presentation/routing/jwt.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.responses import ORJSONResponse +from starlette import status + +from src.application.commands import AdminJwtRefreshCommand +from src.application.domain.exceptions import ApplicationException, RefreshConcurrentException +from src.infrastructure.config import settings +from src.presentation.dependencies.commands import get_admin_jwt_refresh_command + +jwt_router = APIRouter(prefix='/jwt', tags=['jwt']) + + +def _clear_auth_cookies(response: ORJSONResponse) -> None: + response.delete_cookie('access_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) + response.delete_cookie('refresh_token', path='/', domain=settings.ADMIN_COOKIE_DOMAIN) + + +def _set_auth_cookies(response: ORJSONResponse, access: str, refresh: str) -> None: + response.set_cookie( + key='access_token', + value=access, + httponly=True, + secure=settings.ADMIN_COOKIE_SECURE, + samesite='lax', + path='/', + domain=settings.ADMIN_COOKIE_DOMAIN, + max_age=int(settings.JWT_ACCESS_TTL_SECONDS), + ) + response.set_cookie( + key='refresh_token', + value=refresh, + httponly=True, + secure=settings.ADMIN_COOKIE_SECURE, + samesite='lax', + path='/', + domain=settings.ADMIN_COOKIE_DOMAIN, + max_age=int(settings.JWT_REFRESH_TTL_SECONDS), + ) + + +@jwt_router.post('/refresh', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) +async def refresh_tokens( + request: Request, + command: AdminJwtRefreshCommand = Depends(get_admin_jwt_refresh_command), +): + refresh_token = request.cookies.get('refresh_token') + if not refresh_token: + response = ORJSONResponse({'ok': False, 'error': 'No refresh token'}, status_code=401) + _clear_auth_cookies(response) + return response + + xff = request.headers.get('x-forwarded-for') + ip = xff.split(',')[0].strip() if xff else (request.client.host if request.client else None) + user_agent = request.headers.get('user-agent') + + try: + tokens = await command(refresh_token=refresh_token, ip=ip, user_agent=user_agent) + except RefreshConcurrentException: + return ORJSONResponse({'result': True, 'concurrent': True}, status_code=status.HTTP_200_OK) + except ApplicationException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED: + response = ORJSONResponse({'result': False}, status_code=401) + _clear_auth_cookies(response) + return response + raise + + access, refresh = tokens + response = ORJSONResponse({'result': True}) + _set_auth_cookies(response, access, refresh) + return response diff --git a/src/presentation/routing/organizations.py b/src/presentation/routing/organizations.py new file mode 100644 index 0000000..58d63f6 --- /dev/null +++ b/src/presentation/routing/organizations.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, Depends, Query, status + +from src.application.commands import ( + CreateOrganizationCommand, + CreateOrganizationWalletsCommand, + GetOrganizationCommand, + ListOrganizationsCommand, + UpdateOrganizationCommand, +) +from src.application.domain.dto import AdminAuthContext +from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role +from src.presentation.dependencies.commands import ( + get_create_organization_command, + get_create_organization_wallets_command, + get_get_organization_command, + get_list_organizations_command, + get_update_organization_command, +) +from src.presentation.schemas.mappers import organization_to_response, wallet_to_response +from src.presentation.schemas.organization import ( + CreateOrganizationRequest, + OrganizationListResponse, + OrganizationResponse, + UpdateOrganizationRequest, + WalletResponse, +) + +organizations_router = APIRouter(prefix='/organizations', tags=['organizations']) + + +@organizations_router.get('', response_model=OrganizationListResponse) +async def list_organizations( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + auth: AdminAuthContext = Depends(require_admin_access), + command: ListOrganizationsCommand = Depends(get_list_organizations_command), +): + items, total = await command(limit=limit, offset=offset) + return OrganizationListResponse( + items=[organization_to_response(x) for x in items], + total=total, + ) + + +@organizations_router.post('', response_model=OrganizationResponse, status_code=status.HTTP_201_CREATED) +async def create_organization( + body: CreateOrganizationRequest, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: CreateOrganizationCommand = Depends(get_create_organization_command), +): + org = await command( + admin_user_id=auth.admin_user_id, + email=str(body.email), + password=body.password, + name=body.name, + short_name=body.short_name, + inn=body.inn, + ogrn=body.ogrn, + kpp=body.kpp, + legal_address=body.legal_address, + actual_address=body.actual_address, + bank_details=body.bank_details, + contact_person=body.contact_person, + contact_phone=body.contact_phone, + status=body.status, + ) + return organization_to_response(org) + + +@organizations_router.get('/{organization_id}', response_model=OrganizationResponse) +async def get_organization( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetOrganizationCommand = Depends(get_get_organization_command), +): + org = await command(organization_id) + return organization_to_response(org) + + +@organizations_router.patch('/{organization_id}', response_model=OrganizationResponse) +async def update_organization( + organization_id: str, + body: UpdateOrganizationRequest, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: UpdateOrganizationCommand = Depends(get_update_organization_command), +): + org = await command(organization_id, values=body.model_dump(exclude_unset=True)) + return organization_to_response(org) + + +@organizations_router.post( + '/{organization_id}/wallets/create', + response_model=list[WalletResponse], + status_code=status.HTTP_201_CREATED, +) +async def create_organization_wallets( + organization_id: str, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command), +): + wallets = await command(organization_id=organization_id) + return [wallet_to_response(w) for w in wallets] diff --git a/src/presentation/routing/purchase_requests.py b/src/presentation/routing/purchase_requests.py new file mode 100644 index 0000000..0e43a1d --- /dev/null +++ b/src/presentation/routing/purchase_requests.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, Query, status + +from src.application.commands import ( + GetPurchaseRequestCommand, + ListPurchaseRequestsCommand, + SetPurchaseRequestQuoteCommand, + UpdatePurchaseRequestStatusCommand, +) +from src.application.domain.dto import AdminAuthContext +from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role +from src.presentation.dependencies.commands import ( + get_get_purchase_request_command, + get_list_purchase_requests_command, + get_set_purchase_request_quote_command, + get_update_purchase_request_status_command, +) +from src.presentation.schemas.mappers import purchase_request_to_response +from src.presentation.schemas.organization import ( + PurchaseRequestListResponse, + PurchaseRequestResponse, + SetPurchaseRequestQuoteBody, + UpdatePurchaseRequestStatusBody, +) + +purchase_requests_router = APIRouter(prefix='/purchase-requests', tags=['purchase-requests']) + + +@purchase_requests_router.get('', response_model=PurchaseRequestListResponse) +async def list_purchase_requests( + status_filter: str | None = Query(default=None, alias='status'), + organization_id: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + auth: AdminAuthContext = Depends(require_admin_access), + command: ListPurchaseRequestsCommand = Depends(get_list_purchase_requests_command), +): + items, total = await command( + status=status_filter, + organization_id=organization_id, + limit=limit, + offset=offset, + ) + return PurchaseRequestListResponse( + items=[purchase_request_to_response(x) for x in items], + total=total, + ) + + +@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse) +async def get_purchase_request( + request_id: str, + auth: AdminAuthContext = Depends(require_admin_access), + command: GetPurchaseRequestCommand = Depends(get_get_purchase_request_command), +): + item = await command(request_id) + return purchase_request_to_response(item) + + +@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse) +async def update_purchase_request_status( + request_id: str, + body: UpdatePurchaseRequestStatusBody, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: UpdatePurchaseRequestStatusCommand = Depends(get_update_purchase_request_status_command), +): + item = await command( + request_id, + status=body.status, + admin_comment=body.admin_comment, + assigned_to=body.assigned_to, + tx_hash=body.tx_hash, + ) + return purchase_request_to_response(item) + + +@purchase_requests_router.post('/{request_id}/quote', response_model=PurchaseRequestResponse) +async def set_purchase_request_quote( + request_id: str, + body: SetPurchaseRequestQuoteBody, + auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')), + command: SetPurchaseRequestQuoteCommand = Depends(get_set_purchase_request_quote_command), +): + item = await command( + request_id, + rub_amount=body.rub_amount, + exchange_rate=body.exchange_rate, + service_fee_percent=body.service_fee_percent, + admin_comment=body.admin_comment, + ) + return purchase_request_to_response(item) diff --git a/src/presentation/schemas/__init__.py b/src/presentation/schemas/__init__.py new file mode 100644 index 0000000..3c23afb --- /dev/null +++ b/src/presentation/schemas/__init__.py @@ -0,0 +1 @@ +from src.presentation.schemas.error import ErrorResponse diff --git a/src/presentation/schemas/admin_auth.py b/src/presentation/schemas/admin_auth.py new file mode 100644 index 0000000..7f9d624 --- /dev/null +++ b/src/presentation/schemas/admin_auth.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, EmailStr, Field + + +class AdminLoginRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + +class AdminLoginResponse(BaseModel): + access_token: str + token_type: str = 'Bearer' + id: str + email: str + first_name: str | None + last_name: str | None + role: str + + +class AdminMeResponse(BaseModel): + id: str + email: str + first_name: str | None + last_name: str | None + role: str diff --git a/src/presentation/schemas/error.py b/src/presentation/schemas/error.py new file mode 100644 index 0000000..539d35b --- /dev/null +++ b/src/presentation/schemas/error.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel +from pydantic import Field + + +class ErrorResponse(BaseModel): + detail: str = Field(title='Detail') diff --git a/src/presentation/schemas/mappers.py b/src/presentation/schemas/mappers.py new file mode 100644 index 0000000..4adea44 --- /dev/null +++ b/src/presentation/schemas/mappers.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from src.application.domain.entities.organization import ( + LegalEntityEntity, + OrganizationDocumentEntity, + OrganizationWalletEntity, + PurchaseRequestEntity, +) +from src.presentation.schemas.organization import ( + DocumentResponse, + OrganizationResponse, + PurchaseRequestResponse, + WalletResponse, +) + + +def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse: + return OrganizationResponse( + id=entity.id, + user_id=entity.user_id, + name=entity.name, + short_name=entity.short_name, + inn=entity.inn, + ogrn=entity.ogrn, + kpp=entity.kpp, + legal_address=entity.legal_address, + actual_address=entity.actual_address, + bank_details=entity.bank_details, + contact_person=entity.contact_person, + contact_phone=entity.contact_phone, + status=entity.status, + kyc_verified=entity.kyc_verified, + kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None, + has_wallets=bool(entity.encrypted_mnemonic), + created_by=entity.created_by, + created_at=entity.created_at.isoformat() if entity.created_at else None, + updated_at=entity.updated_at.isoformat() if entity.updated_at else None, + ) + + +def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse: + return WalletResponse( + id=entity.id, + chain=entity.chain, + address=entity.address, + derivation_path=entity.derivation_path, + created_at=entity.created_at.isoformat() if entity.created_at else None, + ) + + +def document_to_response( + entity: OrganizationDocumentEntity, + *, + download_url: str | None = None, +) -> DocumentResponse: + return DocumentResponse( + id=entity.id, + organization_id=entity.organization_id, + document_type=entity.document_type, + file_name=entity.file_name, + content_type=entity.content_type, + file_size_bytes=entity.file_size_bytes, + uploaded_by=entity.uploaded_by, + created_at=entity.created_at.isoformat() if entity.created_at else None, + download_url=download_url, + ) + + +def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseRequestResponse: + return PurchaseRequestResponse( + id=entity.id, + organization_id=entity.organization_id, + status=entity.status, + usdt_amount=str(entity.usdt_amount), + rub_amount=str(entity.rub_amount) if entity.rub_amount is not None else None, + exchange_rate=str(entity.exchange_rate) if entity.exchange_rate is not None else None, + service_fee_percent=str(entity.service_fee_percent) if entity.service_fee_percent is not None else None, + comment=entity.comment, + admin_comment=entity.admin_comment, + target_wallet_chain=entity.target_wallet_chain, + target_wallet_address=entity.target_wallet_address, + tx_hash=entity.tx_hash, + assigned_to=entity.assigned_to, + created_at=entity.created_at.isoformat() if entity.created_at else None, + updated_at=entity.updated_at.isoformat() if entity.updated_at else None, + completed_at=entity.completed_at.isoformat() if entity.completed_at else None, + ) diff --git a/src/presentation/schemas/organization.py b/src/presentation/schemas/organization.py new file mode 100644 index 0000000..d272aa8 --- /dev/null +++ b/src/presentation/schemas/organization.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, EmailStr, Field + + +class CreateOrganizationRequest(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + name: str = Field(min_length=1, max_length=512) + short_name: str | None = Field(default=None, max_length=256) + inn: str = Field(min_length=10, max_length=12) + ogrn: str | None = Field(default=None, max_length=15) + kpp: str | None = Field(default=None, max_length=9) + legal_address: str | None = None + actual_address: str | None = None + bank_details: dict[str, Any] | None = None + contact_person: str | None = Field(default=None, max_length=256) + contact_phone: str | None = Field(default=None, max_length=16) + status: str = 'active' + + +class UpdateOrganizationRequest(BaseModel): + name: str | None = Field(default=None, max_length=512) + short_name: str | None = Field(default=None, max_length=256) + ogrn: str | None = Field(default=None, max_length=15) + kpp: str | None = Field(default=None, max_length=9) + legal_address: str | None = None + actual_address: str | None = None + bank_details: dict[str, Any] | None = None + contact_person: str | None = Field(default=None, max_length=256) + contact_phone: str | None = Field(default=None, max_length=16) + status: str | None = None + + +class OrganizationResponse(BaseModel): + id: str + user_id: str + name: str + short_name: str | None + inn: str + ogrn: str | None + kpp: str | None + legal_address: str | None + actual_address: str | None + bank_details: dict[str, Any] | None + contact_person: str | None + contact_phone: str | None + status: str + kyc_verified: bool + kyc_verified_at: str | None + has_wallets: bool + created_by: str | None + created_at: str | None + updated_at: str | None + + +class OrganizationListResponse(BaseModel): + items: list[OrganizationResponse] + total: int + + +class WalletResponse(BaseModel): + id: str + chain: str + address: str + derivation_path: str + created_at: str | None + + +class DocumentResponse(BaseModel): + id: str + organization_id: str + document_type: str + file_name: str + content_type: str + file_size_bytes: int + uploaded_by: str | None + created_at: str | None + download_url: str | None = None + + +class UpdatePurchaseRequestStatusBody(BaseModel): + status: str + admin_comment: str | None = None + assigned_to: str | None = None + tx_hash: str | None = None + + +class SetPurchaseRequestQuoteBody(BaseModel): + rub_amount: Decimal = Field(gt=0) + exchange_rate: Decimal = Field(gt=0) + service_fee_percent: Decimal | None = None + admin_comment: str | None = None + + +class PurchaseRequestResponse(BaseModel): + id: str + organization_id: str + status: str + usdt_amount: str + rub_amount: str | None + exchange_rate: str | None + service_fee_percent: str | None + comment: str | None + admin_comment: str | None + target_wallet_chain: str | None + target_wallet_address: str | None + tx_hash: str | None + assigned_to: str | None + created_at: str | None + updated_at: str | None + completed_at: str | None + + +class PurchaseRequestListResponse(BaseModel): + items: list[PurchaseRequestResponse] + total: int diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cc1a2e8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1610 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "acryl-datahub" +version = "1.5.0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "avro" }, + { name = "avro-gen3" }, + { name = "cached-property" }, + { name = "click" }, + { name = "click-default-group" }, + { name = "click-spinner" }, + { name = "deprecated" }, + { name = "docker" }, + { name = "expandvars" }, + { name = "humanfriendly" }, + { name = "ijson" }, + { name = "jsonref" }, + { name = "jsonschema" }, + { name = "mixpanel" }, + { name = "packaging" }, + { name = "progressbar2" }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "requests-file" }, + { name = "ruamel-yaml" }, + { name = "sentry-sdk" }, + { name = "setuptools" }, + { name = "tabulate" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/ee/c6e356a3f2f023d6741e121bab984b50ad2e7437c5fa96f095d01573ba16/acryl_datahub-1.5.0.19.tar.gz", hash = "sha256:e521f9f4f0cde7fbdc97f7bb3d22ee9aadcc4be87ba6ea421d11468292757531", size = 3286956, upload-time = "2026-05-08T23:46:26.839Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/76/21ee9d057d11ed0f8857c60e62186a631d908e15c761406abb3a926347a9/acryl_datahub-1.5.0.19-py3-none-any.whl", hash = "sha256:6e0600e853ffbad344c0402a6ffa8fc73fe73a4726f7d1d8e9b0a1d208ade02c", size = 3959384, upload-time = "2026-05-08T23:46:21.049Z" }, +] + +[[package]] +name = "acryl-sqlglot" +version = "25.25.2.dev9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/db/3a696a6fd4c1b5f6b938f951a5983e1c971f40a11fa4093a9a4127e1a9e9/acryl_sqlglot-25.25.2.dev9.tar.gz", hash = "sha256:6fee4aec083e61a3614e4ffecaa60419b871d83e33e0642713a60c61f03f48b3", size = 19540016, upload-time = "2024-10-16T21:33:06.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/7d/326d82d67d902788d38c829aa5834b3a22fff81c15c370f945741424a7c9/acryl_sqlglot-25.25.2.dev9-py3-none-any.whl", hash = "sha256:7ea79b43e48bc08f9102d73f0a3bd05577344913db29353ec7161dfa0067a96b", size = 420199, upload-time = "2024-10-16T21:32:46.159Z" }, +] + +[[package]] +name = "admin-service" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "acryl-datahub" }, + { name = "acryl-sqlglot" }, + { name = "aiobotocore" }, + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "bip-utils" }, + { name = "cryptography" }, + { name = "dotenv" }, + { name = "fastapi" }, + { name = "granian" }, + { name = "hvac" }, + { name = "orjson" }, + { name = "pydantic-settings" }, + { name = "python-jose" }, + { name = "python-multipart" }, + { name = "python-ulid" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "acryl-datahub", specifier = ">=1.5.0.19" }, + { name = "acryl-sqlglot", specifier = ">=25.25.2.dev9" }, + { name = "aiobotocore", specifier = ">=2.21.0" }, + { name = "apscheduler", specifier = "==3.11.2" }, + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "bcrypt", specifier = "==5.0.0" }, + { name = "bip-utils", specifier = ">=2.9.3" }, + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "dotenv", specifier = "==0.9.9" }, + { name = "fastapi", specifier = "==0.128.7" }, + { name = "granian", specifier = "==2.6.1" }, + { name = "hvac", specifier = "==2.4.0" }, + { name = "orjson", specifier = "==3.11.7" }, + { name = "pydantic-settings", specifier = "==2.12.0" }, + { name = "python-jose", specifier = "==3.5.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "python-ulid", specifier = "==3.1.0" }, + { name = "redis", specifier = "==7.2.0" }, + { name = "sqlalchemy", specifier = "==2.0.46" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = "==0.22.1" }, +] + +[[package]] +name = "aiobotocore" +version = "3.7.0" +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/e7/75/42cce839c2ec263ff74b10b650fe36b066fbb124cbee6f247eac0983e1ab/aiobotocore-3.7.0.tar.gz", hash = "sha256:c64d871ed5491a6571948dd48eabd185b46c6c23b64e3afd0c059fc7593ada30", size = 127054, upload-time = "2026-05-09T10:02:52.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/5f/85535dfb3cfd6442d66d1df1694062c5d6df02f895329e7e120b2a3d2b8b/aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:680bde7c64679a821a9312641b759d9497f790ba8b2e88c6959e6273ee765b8e", size = 89539, upload-time = "2026-05-09T10:02:50.389Z" }, +] + +[[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.5" +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/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +] + +[[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" }, + { name = "typing-extensions" }, +] +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 = "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 = "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" }, + { name = "typing-extensions" }, +] +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 = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[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/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "avro" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/00/af1eec633637e12d0945a97f05a429eed83ac45865af60cb453db4689d95/avro-1.12.1.tar.gz", hash = "sha256:c5b8dd2dd4c10816f0dc127cc29cfd43b5e405cf7e6840e89460a024bf3d098d", size = 91115, upload-time = "2025-10-15T21:05:33.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/2b/3d5f41d5b46246216fa62d1a0a6813c74ba8b0ef990644b6bd3cde82d042/avro-1.12.1-py2.py3-none-any.whl", hash = "sha256:970475dd6457924533966fe761be607c759d5a48390cc8fbed472f7c9a8868f2", size = 124250, upload-time = "2025-10-15T21:05:32.815Z" }, +] + +[[package]] +name = "avro-gen3" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "avro" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/d2/58133bc977971a846ea2cb89fd4ae27fc71e05efd45c64a5c2996c597491/avro_gen3-0.7.16.tar.gz", hash = "sha256:1ef593e22d8876ec55b91aa75cb0581a4526bae4bb990fde7892208679dc44dc", size = 24057, upload-time = "2024-09-05T23:57:52.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/11/966375cc964f8220f7bee5ac56a6ca81c636e5180d2454396ae5c452e0e2/avro_gen3-0.7.16-py3-none-any.whl", hash = "sha256:9f26de26214a8730d5e7d86b4a2c4afe8bedfaac5b770beb122cd0fa5fea60f8", size = 27604, upload-time = "2024-09-05T23:57:51.33Z" }, +] + +[[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/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" }, +] + +[[package]] +name = "bip-utils" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "coincurve" }, + { name = "crcmod" }, + { name = "ecdsa" }, + { name = "ed25519-blake2b-fork" }, + { name = "py-sr25519-bindings" }, + { name = "pycryptodome" }, + { name = "pynacl" }, + { name = "pytoniq-core-fork" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1b/417d040f1f31a9438eef1f874655db126662e7293c9bbaf28d056616cedd/bip_utils-2.12.1.tar.gz", hash = "sha256:23a88779620a981237227c7482bc316f8ce877a543c736fb00b1e4a7762d1978", size = 608036, upload-time = "2026-03-02T11:43:02.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/4d/d5be7df07e694d2a238808a9a8db20e0b2a722c087b63915d901a1dcb3db/bip_utils-2.12.1-py3-none-any.whl", hash = "sha256:167189f40bad8e747f35b54764eebdf9fabd3b165293149951e5ccbf5a624a2c", size = 634612, upload-time = "2026-03-02T11:43:01.304Z" }, +] + +[[package]] +name = "bitarray" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/47/b5da717e7bbe97a6dc4c986f053ca55fd3276078d78f68f9e8b417d1425a/bitarray-3.8.1.tar.gz", hash = "sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5", size = 152471, upload-time = "2026-04-02T16:29:01.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/4f/6ab3767b6642a6cbee4353f10a71fe25ade9899d539fae47c3d50686ebe2/bitarray-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d", size = 149202, upload-time = "2026-04-02T16:26:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/eb/53/22bfffd13dd0a266f90011338b24eec45f25c91d37155bb2aa330351e17d/bitarray-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644", size = 145999, upload-time = "2026-04-02T16:26:56.849Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/60aff29c88b648e18248921001cf9d7169abeda4d8db96f2dc1a24ed98ca/bitarray-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441", size = 335945, upload-time = "2026-04-02T16:26:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/225380610a01ae0d8f2f5256e531bae7135b2ade6f4607156424718ec43a/bitarray-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b", size = 364213, upload-time = "2026-04-02T16:26:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/6c/df/83899be9a74ec5878972e8b636f645ef1771e146c6425a161fdafdd74aaa/bitarray-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1", size = 375409, upload-time = "2026-04-02T16:27:01.081Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/38bc15cb097107d220a942eb66dc50882496d7da54f41e5eea6c31b1c443/bitarray-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e", size = 343645, upload-time = "2026-04-02T16:27:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c3/75fae6991946f8bf643ec50233432ea81b5b65bfdb2918b09d7e37605380/bitarray-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f", size = 333844, upload-time = "2026-04-02T16:27:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7e/649e7c3bb12ba938c387bcad6a6c0b84312663c9807ec1457888936690d8/bitarray-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf", size = 361267, upload-time = "2026-04-02T16:27:05.361Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/db0fb71a7c6c3ef047b84256157e96fa35e10ed8b79b80e892d354ab37f6/bitarray-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459", size = 359373, upload-time = "2026-04-02T16:27:07.106Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b6/a082d84cba7ba509b48d160034f6a2d31df6bf4fff0471801e888bba96c9/bitarray-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4", size = 340633, upload-time = "2026-04-02T16:27:08.794Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/1ba7ec1f3aa62933dfef505b09de0b75778a3cb05984ee8bb798539381db/bitarray-3.8.1-cp312-cp312-win32.whl", hash = "sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123", size = 143521, upload-time = "2026-04-02T16:27:10.423Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/5ff9d30a1121810f336517e51b1cbdea0fa92e92b142efe0741e335dc14e/bitarray-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b", size = 150451, upload-time = "2026-04-02T16:27:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/a6/08/51e49eb09ca45ecda4a5f05b70a10977a5f0ac39967c79479e9d3e41cb29/bitarray-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576", size = 147218, upload-time = "2026-04-02T16:27:13.566Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/79/2f4be1896db3db7ccf44504253a175d56b6bd6b669619edc5147d1aa21ea/botocore-1.43.0.tar.gz", hash = "sha256:e933b31a2d644253e1d029d7d39e99ba41b87e29300534f189744cc438cdf928", size = 15286817, upload-time = "2026-04-29T22:07:31.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/4b/afc1fef8a43bafb139f57f73bbd70df82807af5934321e8112ae50668827/botocore-1.43.0-py3-none-any.whl", hash = "sha256:cc5b15eaec3c6eac05d8012cb5ef17ebe891beb88a16ca13c374bfaece1241e6", size = 14970102, upload-time = "2026-04-29T22:07:27Z" }, +] + +[[package]] +name = "cached-property" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/4b/3d870836119dbe9a5e3c9a61af8cc1a8b69d75aea564572e385882d5aefb/cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", size = 10574, upload-time = "2024-10-25T15:43:55.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/0e/7d8225aab3bc1a0f5811f8e1b557aa034ac04bdf641925b30d3caf586b28/cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb", size = 7428, upload-time = "2024-10-25T15:43:54.711Z" }, +] + +[[package]] +name = "cbor2" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" }, + { url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/cf/20/9a22cfe08be16ddfeef2542cf4eeed1b29f3f57ddbba0b42f7e0bb8331fd/cbor2-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:34a6cb15e6ab6a8eae94ad2041731cd3ef786af43a8df99f847969af5b902ee7", size = 70049, upload-time = "2026-03-22T15:56:20.502Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/695f92d09006614034e25a9f5b10620f3b219f79c1bec3c37b7c6f27a7a9/cbor2-5.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d1ddc4541e7367ac58c2470cc0df847f7137167fe4f5729e2d3cc0b993d7da4", size = 65382, upload-time = "2026-03-22T15:56:21.526Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[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/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-default-group" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/ce/edb087fb53de63dad3b36408ca30368f438738098e668b78c87f93cd41df/click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e", size = 3505, upload-time = "2023-08-04T07:54:58.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/1a/aff8bb287a4b1400f69e09a53bd65de96aa5cee5691925b38731c67fc695/click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f", size = 4123, upload-time = "2023-08-04T07:54:56.875Z" }, +] + +[[package]] +name = "click-spinner" +version = "0.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/3a/7dbc558fcf0ae9e2e8b7ccc52daeb4eaf32b21f851497f5b409e1638dcee/click-spinner-0.1.10.tar.gz", hash = "sha256:87eacf9d7298973a25d7615ef57d4782aebf913a532bba4b28a37e366e975daf", size = 18720, upload-time = "2020-04-24T07:14:51.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/2a/04893832bfeddc2d40a7de2e8153b3085f12d63507d91a9cf0157dc3a1c2/click_spinner-0.1.10-py2.py3-none-any.whl", hash = "sha256:d1ffcff1fdad9882396367f15fb957bcf7f5c64ab91927dee2127e0d2991ee84", size = 3986, upload-time = "2020-04-24T07:14:50.575Z" }, +] + +[[package]] +name = "coincurve" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/a2/f2a38eb05b747ed3e54e1be33be339d4a14c1f5cc6a6e2b342b5e8160d51/coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5", size = 128986, upload-time = "2025-03-08T15:31:24.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/61/a2d9e109f99b6f5e65e653ac998b0944c5b82c568ac142fcbb381a4803be/coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c", size = 1391948, upload-time = "2025-03-08T15:30:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/2da75ee00a722ef1fa068ada3bc34c564595ead86fef573434e2f0cb0a5c/coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2", size = 1384958, upload-time = "2025-03-08T15:30:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/50/6bf0bf7e8a9a9dd419ecc1e479dcb9fbfe657029276ad703806a25a2bef2/coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b", size = 1606576, upload-time = "2025-03-08T15:30:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ab/9e89908fdd09ad522938085587aaa821b022f4def16c286c5580cfc85811/coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4", size = 1613642, upload-time = "2025-03-08T15:30:38.416Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/050b6fd08978de85a7b480f0f220ab6a30967c0910119f3096a8dd40befc/coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715", size = 1616974, upload-time = "2025-03-08T15:30:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/d7/62/2740ba0cafebf45708633635fecadcbe582d7a3ed1ce8b4637921feceaf8/coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3", size = 1644133, upload-time = "2025-03-08T15:30:41.733Z" }, + { url = "https://files.pythonhosted.org/packages/94/14/1f27c3048c4084fa85ef65f42a4ca631f2b184336e6d9446fecec20e0a7f/coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9", size = 1619918, upload-time = "2025-03-08T15:30:43.284Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/7ec3ec4c8e7764daa25767d6674cb5741ea2d9b39ff758e9918d22a4b49b/coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8", size = 1645797, upload-time = "2025-03-08T15:30:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/fb/60/87982b7499943ab12605df7b14f6001fff331aca0881b260682461e2309d/coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1", size = 1329255, upload-time = "2025-03-08T15:30:46.4Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/65b60b371579570931daca8a3f67debfc1482908b8ed03432297274a27da/coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d", size = 1325973, upload-time = "2025-03-08T15:30:48.056Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "crcmod" +version = "1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[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 = "ed25519-blake2b-fork" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/a1/ab4571f6aba346189658856e72a04a9383a62c6a20df2ffec2d04093b00a/ed25519_blake2b_fork-1.4.2.tar.gz", hash = "sha256:dd5d2f645b9a2f7e9834bae56ee348182e19f1528495e7186761384d8afd7f6f", size = 854701, upload-time = "2026-03-02T10:52:32.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/96/8b848b23bcbf073d3ba29fc61481f93e42c70bf87892f9c1ad1742d01c42/ed25519_blake2b_fork-1.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:140bbb201d46b7503a670cb0c86a19e441ef2f7000ba129657aa7e9b48b7b166", size = 69066, upload-time = "2026-03-02T10:51:37.882Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a1/72642b5fa519f485e356e21e7a9c76787c22e23faa645c4d4fa73fd88fe9/ed25519_blake2b_fork-1.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01867398cebd57fb5bc2b3d71d5254ab888f8d3f03304d00a08dbd8104795d92", size = 72275, upload-time = "2026-03-02T10:51:39.027Z" }, + { url = "https://files.pythonhosted.org/packages/00/29/850dbdaed99b2e35773ffa79123159100986c05b236425e8099e82eb9ec5/ed25519_blake2b_fork-1.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae18f3e95a44b988c07115b991f7d3a27ed85a0756414036b230c4858a415f6", size = 124090, upload-time = "2026-03-02T10:51:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/ab4a6946f25aa41fe86aae186809898534ab8dd2e9db750408f33ab422ff/ed25519_blake2b_fork-1.4.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2581152fe32a26b4bb7c9137b88fb203bad1283337086d52012a5269eedcbe", size = 132772, upload-time = "2026-03-02T10:51:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/748f1e4c86c3e627388a8079bd8d299d50ac66b254606cc2ddd96d07a283/ed25519_blake2b_fork-1.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e597df82ac012859210ff5303da5f4514b9ae68f1430d338e369d947a9300ce1", size = 132751, upload-time = "2026-03-02T10:51:42.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cd/5326c5c2163c3f36bd75814b5026b8a916cc5f1eb024adce39e4e3d6bf37/ed25519_blake2b_fork-1.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cf0c4786a6df78b2cd806e2ba2d2875c2b6821097fe9d2882769f8978d2ac71", size = 122348, upload-time = "2026-03-02T10:51:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/76/b4/2683c7901c935ca044def4c5c35ba62a17c4084ad4519c222de25b7440b6/ed25519_blake2b_fork-1.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7d44ff2567bfa6c2d8e14c01f5326fb11240495b7ce7cc7a9ce3bd3573eba86", size = 131012, upload-time = "2026-03-02T10:51:43.977Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ae/4b5291e1e7ba13fecb9b59a214738de146dc6babb375c08bb2e21e42c9e3/ed25519_blake2b_fork-1.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fb986e6688171e96b1060c2f1b66074f0b9bb1904f3ebe51a5341fed108b5c0", size = 132893, upload-time = "2026-03-02T10:51:45.327Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a7/2ed06967612d8a1bef616d2caec202cd92685e73316cccf803bfa7e70c36/ed25519_blake2b_fork-1.4.2-cp312-cp312-win32.whl", hash = "sha256:4b197d93236f362ea2b35e515bb4da4a3e88ac5fa4e2337979457a621e299aca", size = 74738, upload-time = "2026-03-02T10:51:47.389Z" }, + { url = "https://files.pythonhosted.org/packages/77/ea/3fa19226663d2f6e672978317558857d5a9fe20d1398d964191b9b1ec59c/ed25519_blake2b_fork-1.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:848afcd9b3cc8f88e032525ddc3112acf62b62813a9bc49785116b86f5b30b20", size = 71137, upload-time = "2026-03-02T10:51:48.234Z" }, +] + +[[package]] +name = "expandvars" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/64/a9d8ea289d663a44b346203a24bf798507463db1e76679eaa72ee6de1c7a/expandvars-1.1.2.tar.gz", hash = "sha256:6c5822b7b756a99a356b915dd1267f52ab8a4efaa135963bd7f4bd5d368f71d7", size = 70842, upload-time = "2025-09-12T10:55:20.929Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/e6/79c43f7a55264e479a9fbf21ddba6a73530b3ea8439a8bb7fa5a281721af/expandvars-1.1.2-py3-none-any.whl", hash = "sha256:d1652fe4e61914f5b88ada93aaedb396446f55ae4621de45c8cb9f66e5712526", size = 7526, upload-time = "2025-09-12T10:55:18.779Z" }, +] + +[[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/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { 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 = "granian" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/22/93016f4f9e9115ba981f51fc17c7c369a34772f11a93043320a2a3d5c6ea/granian-2.6.1.tar.gz", hash = "sha256:d209065b12f18b6d7e78f1c16ff9444e5367dddeb41e3225c2cf024762740590", size = 115480, upload-time = "2026-01-07T11:08:55.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/d1/9d191ea0b4f01a0d2437600b32a025e687189bae072878ec161f358eb465/granian-2.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:801bcf7efc3fdd12a08016ed94b1a386480c9a5185eb8e017fd83db1b2d210b4", size = 3070339, upload-time = "2026-01-07T11:07:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1e/be0ba55a2b21aeadeb8774721964740130fdd3dd7337d8a5ec130a0c48c0/granian-2.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:853fb869a50d742576bb4f974f321242a71a4d8eed918939397b317ab32c6a2d", size = 2819049, upload-time = "2026-01-07T11:07:23.877Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/d8adb472dc71b212281a82d3ea00858809f2844a79b45e63bbb3a09921b7/granian-2.6.1-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:327a6090496c1deebd9e315f973bdbfc5c927e5574588bba918bfe2127bbd578", size = 3322325, upload-time = "2026-01-07T11:07:25.304Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/c3ce9e4f19163f35c5c57c45af2ad353abcc6091a44625caec56e065ca4a/granian-2.6.1-cp312-cp312-manylinux_2_24_i686.whl", hash = "sha256:4c91f0eefc34d809773762a9b81c1c48e20ff74c0f1be876d1132d82c0f74609", size = 3136460, upload-time = "2026-01-07T11:07:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/3d/87/91b57eb5407a12bfe779acfa3fbb2be329aec14e6d88acf293fe910c19e5/granian-2.6.1-cp312-cp312-manylinux_2_24_x86_64.whl", hash = "sha256:c5754de57b56597d5998b7bb40aa9d0dc4e1dbeb5aea3309945126ed71b41c6d", size = 3386850, upload-time = "2026-01-07T11:07:27.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/b61a6f3bfc2f35e504e42789776a269cbdc0cdafdb10597bd6534e93ba3d/granian-2.6.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e849d6467ebe77d0a75eb4175f7cc06b1150dbfce0259932a4270c765b4de6c4", size = 3240693, upload-time = "2026-01-07T11:07:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1d/c40bd8dd99b855190d67127e0610f082cfbc7898dbd41f1ade015c2041f7/granian-2.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5a265867203e30d3c54d9d99783346040681ba2aaec70fcbe63de0e295e7882f", size = 3312703, upload-time = "2026-01-07T11:07:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ca/589c042afc3287b36dfeed6df56074cc831a94e5217bcbd7c1af20812fe2/granian-2.6.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:03f0a1505e7862183203d7d7c1e2b29349bd63a18858ced49aec4d7aadb98fc8", size = 3483737, upload-time = "2026-01-07T11:07:32.726Z" }, + { url = "https://files.pythonhosted.org/packages/6f/51/72eb037bac01db9623fa5fb128739bfb5679fb90e6da2645c5a3d8a4168d/granian-2.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:703ed57ba134ab16f15d49f7d644329db1cb0f7f8114ec3f08fb8039850e308a", size = 3514745, upload-time = "2026-01-07T11:07:34.706Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/be9d5e97d3775dfc0f98b56a85ad6c73d7b0ac4cfc452558696e061d038d/granian-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c771949707118116fa78b03511e690cb6c3bd94e9d84db7c2bdfe0250fecc80", size = 2349022, upload-time = "2026-01-07T11:07:36.484Z" }, +] + +[[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/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, +] + +[[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 = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "hvac" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/57/b46c397fb3842cfb02a44609aa834c887f38dd75f290c2fc5a34da4b2fee/hvac-2.4.0.tar.gz", hash = "sha256:e0056ad9064e7923e874e6769015b032580b639e29246f5ab1044f7959c1c7e0", size = 332543, upload-time = "2025-10-30T12:57:47.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/71e45a6bd6875f44a26f99da31c63b6840123e88bedf2c0b1ce429b8be12/hvac-2.4.0-py3-none-any.whl", hash = "sha256:008db5efd8c2f77bd37d2368ea5f713edceae1c65f11fd608393179478649e0f", size = 155921, upload-time = "2025-10-30T12:57:46.253Z" }, +] + +[[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 = "ijson" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/57/60d1a6a512f2f0508d0bc8b4f1cc5616fd3196619b66bd6a01f9155a1292/ijson-3.5.0.tar.gz", hash = "sha256:94688760720e3f5212731b3cb8d30267f9a045fb38fb3870254e7b9504246f31", size = 68658, upload-time = "2026-02-24T03:58:30.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/17/9c63c7688025f3a8c47ea717b8306649c8c7244e49e20a2be4e3515dc75c/ijson-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ebefbe149a6106cc848a3eaf536af51a9b5ccc9082de801389f152dba6ab755", size = 88536, upload-time = "2026-02-24T03:57:06.809Z" }, + { url = "https://files.pythonhosted.org/packages/6f/dd/e15c2400244c117b06585452ebc63ae254f5a6964f712306afd1422daae0/ijson-3.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e30d9f00f82e64de689c0b8651b9cfed879c184b139d7e1ea5030cec401c21", size = 60499, upload-time = "2026-02-24T03:57:09.155Z" }, + { url = "https://files.pythonhosted.org/packages/77/a9/bf4fe3538a0c965f16b406f180a06105b875da83f0743e36246be64ef550/ijson-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a04a33ee78a6f27b9b8528c1ca3c207b1df3b8b867a4cf2fcc4109986f35c227", size = 60330, upload-time = "2026-02-24T03:57:10.574Z" }, + { url = "https://files.pythonhosted.org/packages/31/76/6f91bdb019dd978fce1bc5ea1cd620cfc096d258126c91db2c03a20a7f34/ijson-3.5.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d48dc2984af02eb3c56edfb3f13b3f62f2f3e4fe36f058c8cfc75d93adf4fed", size = 138977, upload-time = "2026-02-24T03:57:11.932Z" }, + { url = "https://files.pythonhosted.org/packages/11/be/bbc983059e48a54b0121ee60042979faed7674490bbe7b2c41560db3f436/ijson-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1e73a44844d9adbca9cf2c4132cd875933e83f3d4b23881fcaf82be83644c7d", size = 149785, upload-time = "2026-02-24T03:57:13.255Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/2fee58f9024a3449aee83edfa7167fb5ccd7e1af2557300e28531bb68e16/ijson-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7389a56b8562a19948bdf1d7bae3a2edc8c7f86fb59834dcb1c4c722818e645a", size = 149729, upload-time = "2026-02-24T03:57:14.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/56/f1706761fcc096c9d414b3dcd000b1e6e5c24364c21cfba429837f98ee8d/ijson-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3176f23f8ebec83f374ed0c3b4e5a0c4db7ede54c005864efebbed46da123608", size = 150697, upload-time = "2026-02-24T03:57:15.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6e/ee0d9c875a0193b632b3e9ccd1b22a50685fb510256ad57ba483b6529f77/ijson-3.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6babd88e508630c6ef86c9bebaaf13bb2fb8ec1d8f8868773a03c20253f599bc", size = 142873, upload-time = "2026-02-24T03:57:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bf/f9d4399d0e6e3fd615035290a71e97c843f17f329b43638c0a01cf112d73/ijson-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dc1b3836b174b6db2fa8319f1926fb5445abd195dc963368092103f8579cb8ed", size = 151583, upload-time = "2026-02-24T03:57:17.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/a7254a065933c0e2ffd3586f46187d84830d3d7b6f41cfa5901820a4f87d/ijson-3.5.0-cp312-cp312-win32.whl", hash = "sha256:6673de9395fb9893c1c79a43becd8c8fbee0a250be6ea324bfd1487bb5e9ee4c", size = 53079, upload-time = "2026-02-24T03:57:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7b/2edca79b359fc9f95d774616867a03ecccdf333797baf5b3eea79733918c/ijson-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f4f7fabd653459dcb004175235f310435959b1bb5dfa8878578391c6cc9ad944", size = 55500, upload-time = "2026-02-24T03:57:20.428Z" }, +] + +[[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 = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mixpanel" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1e/d2a733cb35b380942e0f1791427d23afa1bc1c37b8ce7089b2c691176d0f/mixpanel-5.0.0.tar.gz", hash = "sha256:ef78b7b36150fbf24796972bf44871515c6d1381a69be683eb26a65d44b84bb5", size = 21083, upload-time = "2025-11-04T22:10:23.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/05/248580e987b203c24e4cce53f5e5d60ea749550af5c756c770196d549f96/mixpanel-5.0.0-py3-none-any.whl", hash = "sha256:5c5fee75a304bd2940828a08a31f74fae7f02d5fe81914a851ed87e8884096ec", size = 23848, upload-time = "2025-11-04T22:10:22.233Z" }, +] + +[[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/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { 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-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 = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "progressbar2" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/24/3587e795fc590611434e4bcb9fbe0c3dddb5754ce1a20edfd86c587c0004/progressbar2-4.5.0.tar.gz", hash = "sha256:6662cb624886ed31eb94daf61e27583b5144ebc7383a17bae076f8f4f59088fb", size = 101449, upload-time = "2024-08-28T22:50:12.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/448f037fb0ffd0e8a63b625cf9f5b13494b88d15573a987be8aaa735579d/progressbar2-4.5.0-py3-none-any.whl", hash = "sha256:625c94a54e63915b3959355e6d4aacd63a00219e5f3e2b12181b76867bf6f628", size = 57132, upload-time = "2024-08-28T22:50:10.264Z" }, +] + +[[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/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { 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 = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "py-sr25519-bindings" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/9a/0b23158cf35321bd14af2ea5868b45cc7380af30bbde730d2b152d35fc20/py_sr25519_bindings-0.2.3.tar.gz", hash = "sha256:5a519bc23b4e8993851e62dd625594329e23bfea479137ba037446a35ec839c4", size = 18001, upload-time = "2025-11-20T10:18:35.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/0f/dabed8deaf4a9fc3f31f73fdd0f3548ddfc8a73dacf055aaf5986cd6a5b2/py_sr25519_bindings-0.2.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:83538423c2955e132830a9de6e5196d757fe88ca46ca082b66d29c8fba07ff65", size = 338900, upload-time = "2025-11-20T10:17:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/5e/98/aee67dd40dcf09c0b167ee7d2f7e02fd60995feef455c1195ff5fdcedb37/py_sr25519_bindings-0.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4443adf871e224493c4ee4c06be205a10ea649a781132af883f6638fd7acc9d7", size = 312148, upload-time = "2025-11-20T10:17:19.341Z" }, + { url = "https://files.pythonhosted.org/packages/74/9f/66047b9ce7af41663e997e084e53f8c93c3f4644a2a895a6f9259a25ead1/py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3929c291408e67a1a11566f251b9f7d06c3fb3ae240caec44b9181de09e3fc9", size = 345098, upload-time = "2025-11-20T10:16:06.741Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/bd12d535cb0ae36fd47698bc853ff0ad1221bd423784142649deb3d01440/py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:619977b94225f559e68e4dd18611f14ed61a2c14d34335bb8ad136e84dd9ce7f", size = 373311, upload-time = "2025-11-20T10:16:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/76/99/63aca8766ce656c1bb84309c105df6f8be7afbe763b2141b352765a26dbf/py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2a2e6eb837263479bccd59a4563b537212dd99e57d4b921c3b0b7717bf9f2e1", size = 480545, upload-time = "2025-11-20T10:16:40.245Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/05d8b0b21aa03b806fbc38960f482228af39bc54d17117665c6e0e07c0a9/py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da4c9c7f9a0a0e8e3d9ed6eedc885561288edd72267ebc7b0fd11262e8c8b28", size = 372984, upload-time = "2025-11-20T10:17:09.278Z" }, + { url = "https://files.pythonhosted.org/packages/74/67/b8f42c2b6e222dc4d272082e65dcf70159e98620606f69e6813261b6ea44/py_sr25519_bindings-0.2.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b92fc5e18c0c5e7c75fa6a49b48914b8e2968e42a0825449241a46ca00546d6c", size = 390521, upload-time = "2025-11-20T10:16:56.284Z" }, + { url = "https://files.pythonhosted.org/packages/bf/86/216779bf7b88ff6ab788fa0f17d8ed6e1f9b6f15e3ab71d978fcf3feaff0/py_sr25519_bindings-0.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f1b818203e84a2a6f059ed2763d5506b3772127c02ffd428163b033f91c1ad92", size = 526879, upload-time = "2025-11-20T10:17:31.414Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/73f54865d5831bc159020ca47873f35e0f9f990a9031a6dd44494a676a73/py_sr25519_bindings-0.2.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:feacb4aa1adc9b15caf516fb14f2f3d95de7451b67f757da576a7184f34d397a", size = 641311, upload-time = "2025-11-20T10:17:47.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6c/90ca04381158f266719a8667001566b5c1d56797eb61a52376629655132f/py_sr25519_bindings-0.2.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7afaa64cc36539df44779f3ff108cfef93c5462e9e28ac832f8329e4c4c045bd", size = 567535, upload-time = "2025-11-20T10:18:04.003Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/72bc7f19483602f6b6e27c76e1a61ebc378bd2f6100a0658af641ecd1072/py_sr25519_bindings-0.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2369d7cb3d5ed41e1d797d1c8e9819b0c31377f18ec6fe685bde632586107da2", size = 539863, upload-time = "2025-11-20T10:18:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/07/22/4e4b9e12a3013f433ce65185ea6303f8eb00a8d7812e6f13c8d9ac616ebb/py_sr25519_bindings-0.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:75ad9a3f11b54e2b8fb2db794e3d75a9baedddc9db583985ade536a1103a2d8d", size = 226020, upload-time = "2025-11-20T10:18:39.701Z" }, +] + +[[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/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 = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[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/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[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 = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, +] + +[[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 = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, +] + +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + +[[package]] +name = "python-utils" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/4c/ef8b7b1046d65c1f18ca31e5235c7d6627ca2b3f389ab1d44a74d22f5cc9/python_utils-3.9.1.tar.gz", hash = "sha256:eb574b4292415eb230f094cbf50ab5ef36e3579b8f09e9f2ba74af70891449a0", size = 35403, upload-time = "2024-11-26T00:38:58.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/69/31c82567719b34d8f6b41077732589104883771d182a9f4ff3e71430999a/python_utils-3.9.1-py2.py3-none-any.whl", hash = "sha256:0273d7363c7ad4b70999b2791d5ba6b55333d6f7a4e4c8b6b39fb82b5fab4613", size = 32078, upload-time = "2024-11-26T00:38:57.488Z" }, +] + +[[package]] +name = "pytoniq-core-fork" +version = "0.1.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "pycryptodomex" }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "x25519" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/aa/f345c0bdb04dc7f323fdf0708129e54ca95c0435ff57a4b404f8d628c9fd/pytoniq_core_fork-0.1.48.tar.gz", hash = "sha256:eeb22d5f3d9fb15daccc48df5b2faf53e45426503c51bf665b56138a6955ae4a", size = 115892, upload-time = "2026-02-27T10:38:53.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/5f/b967b7812438fd72b06c2a0d68299167d5d96147fb1af38ed65c91f00fbc/pytoniq_core_fork-0.1.48-py3-none-any.whl", hash = "sha256:248b0d0b2deffd6935599208ae587dc703fccf2756d735420e95804b5738177f", size = 106753, upload-time = "2026-02-27T10:38:52.047Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, +] + +[[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/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[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 = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, +] + +[[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 = "ruamel-yaml" +version = "0.18.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[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/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { 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" }, + { name = "typing-extensions" }, +] +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 = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[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-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[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 = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[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 = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "x25519" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/b6/fca895aff0800cdf941f856df0685a5513094163664b904576e3e3ef1460/x25519-0.0.2.tar.gz", hash = "sha256:ed91d0aba7f4f4959ed8b37118c11d94f56d36c38bb6f2e6c20d0438d75b1556", size = 4833, upload-time = "2021-10-24T15:18:38.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d1/66c637eb8e7a9601675bf7f04bb9a3015358a0f49e4c31d29a2b9a9d72d9/x25519-0.0.2-py3-none-any.whl", hash = "sha256:5c0833260a548bea9137a5a1b5c30334b751a59d148a62832df0c9e7b919ce99", size = 4907, upload-time = "2021-10-24T15:18:36.727Z" }, +] + +[[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/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { 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" }, +]