init commit
This commit is contained in:
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# 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
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "kyc"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
requires-python = "==3.12.*"
|
||||
dependencies = [
|
||||
"aiohttp==3.13.5",
|
||||
"apscheduler==3.11.2",
|
||||
"asyncpg==0.31.0",
|
||||
"bcrypt==5.0.0",
|
||||
"dotenv==0.9.9",
|
||||
"email-validator==2.3.0",
|
||||
"fastapi==0.128.7",
|
||||
"faststream[rabbit]==0.6.6",
|
||||
"granian==2.6.1",
|
||||
"hvac==2.4.0",
|
||||
"itsdangerous==2.2.0",
|
||||
"orjson==3.11.7",
|
||||
"pydantic-settings==2.12.0",
|
||||
"python-jose==3.5.0",
|
||||
"python-ulid==3.1.0",
|
||||
"redis==7.2.0",
|
||||
"sqlalchemy==2.0.46",
|
||||
"uvloop==0.22.1; platform_system != 'Windows'",
|
||||
]
|
||||
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
19
src/application/abstractions/i_unit_of_work.py
Normal file
19
src/application/abstractions/i_unit_of_work.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
from src.application.abstractions.repositories import IKycRepository,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 kyc_repository(self) -> IKycRepository: ...
|
||||
|
||||
2
src/application/abstractions/repositories/__init__.py
Normal file
2
src/application/abstractions/repositories/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.abstractions.repositories.i_kyc_repository import IKycRepository
|
||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
||||
@@ -0,0 +1,47 @@
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from src.application.domain.entities import KycEntity
|
||||
|
||||
|
||||
class IKycRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_started_session(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str | None,
|
||||
client_user_token: str | None,
|
||||
link: str | None,
|
||||
qr_code: str | None,
|
||||
expires_at: datetime,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_session_result(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str,
|
||||
status: str,
|
||||
done_state: bool | None,
|
||||
set_id: str | None,
|
||||
result_data: Any,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def expire_started_sessions(self,*,user_id: str,now: datetime) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_active_session(self,*,user_id: str,now: datetime) -> KycEntity | None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,34 @@
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from datetime import date
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class IUserRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_user(self, email: str, password_hash: str) -> 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
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_kyc_data(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
birth_date: date,
|
||||
middle_name: str | None,
|
||||
inn: str | None,
|
||||
) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
1
src/application/commands/__init__.py
Normal file
1
src/application/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.commands.create_kyc_command import CompleteKycCommand,GetKycSessionCommand,PassKycCommand
|
||||
183
src/application/commands/create_kyc_command.py
Normal file
183
src/application/commands/create_kyc_command.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import orjson
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IBeorgService,ICache,ILogger,IQueueMessanger
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
|
||||
|
||||
|
||||
KYC_SESSION_TTL = 3600
|
||||
|
||||
|
||||
class PassKycCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
cache: ICache,
|
||||
beorg_service: IBeorgService,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._beorg_service = beorg_service
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> BeorgKycCreateResponse:
|
||||
result = await self._beorg_service.create_identification(client_user_token=user_id)
|
||||
expires_at = _utc_now() + timedelta(seconds=KYC_SESSION_TTL)
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.create_started_session(
|
||||
user_id=user_id,
|
||||
user_token=result.user_token,
|
||||
client_user_token=result.client_user_token,
|
||||
link=result.link,
|
||||
qr_code=result.qr_code,
|
||||
expires_at=expires_at,
|
||||
error=result.error,
|
||||
)
|
||||
await self._cache.set(f'kyc:session:{user_id}',result.model_dump_json(),ttl=KYC_SESSION_TTL)
|
||||
self._logger.info(f'KYC started for user {user_id}')
|
||||
return result
|
||||
|
||||
|
||||
class CompleteKycCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
cache: ICache,
|
||||
beorg_service: IBeorgService,
|
||||
queue_messanger: IQueueMessanger,
|
||||
verified_queue: str,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._beorg_service = beorg_service
|
||||
self._queue_messanger = queue_messanger
|
||||
self._verified_queue = verified_queue
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> BeorgKycResultResponse:
|
||||
session = await self._get_session(user_id)
|
||||
if not session.user_token:
|
||||
raise ApplicationException(status_code=409,message='KYC session has no user token')
|
||||
|
||||
result = await self._beorg_service.get_result(user_token=session.user_token)
|
||||
if result.done_state is None:
|
||||
raise ApplicationException(status_code=409,message='KYC is not completed yet')
|
||||
if result.done_state is False:
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.update_session_result(
|
||||
user_id=user_id,
|
||||
user_token=session.user_token,
|
||||
status='failed',
|
||||
done_state=result.done_state,
|
||||
set_id=result.set_id,
|
||||
result_data=result.data,
|
||||
error='KYC failed',
|
||||
)
|
||||
raise ApplicationException(status_code=400,message='KYC failed')
|
||||
|
||||
personal_data = extract_personal_data(result.data)
|
||||
birth_date = parse_birth_date(personal_data.birth_date)
|
||||
ensure_adult(birth_date)
|
||||
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
user = await unit_of_work.user_repository.update_kyc_data(
|
||||
user_id=user_id,
|
||||
first_name=personal_data.first_name,
|
||||
last_name=personal_data.last_name,
|
||||
middle_name=personal_data.middle_name,
|
||||
birth_date=birth_date,
|
||||
inn=personal_data.inn,
|
||||
)
|
||||
await unit_of_work.kyc_repository.update_session_result(
|
||||
user_id=user_id,
|
||||
user_token=session.user_token,
|
||||
status='completed',
|
||||
done_state=result.done_state,
|
||||
set_id=result.set_id,
|
||||
result_data=result.data,
|
||||
error=None,
|
||||
)
|
||||
await self._cache.set_user(user_id,user,ttl=KYC_SESSION_TTL)
|
||||
await self._cache.delete(f'kyc:session:{user_id}')
|
||||
await self._queue_messanger.publish_to_queue(
|
||||
self._verified_queue,
|
||||
{
|
||||
'user_id': user_id,
|
||||
'kyc_verified': True,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'middle_name': user.middle_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'inn': user.inn,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
},
|
||||
message_id=str(ULID()),
|
||||
correlation_id=user_id,
|
||||
)
|
||||
self._logger.info(f'KYC completed for user {user_id}')
|
||||
return result
|
||||
|
||||
|
||||
async def _get_session(self,user_id: str) -> BeorgKycCreateResponse:
|
||||
raw = await self._cache.get(f'kyc:session:{user_id}')
|
||||
if raw is not None:
|
||||
return BeorgKycCreateResponse.model_validate(orjson.loads(raw))
|
||||
|
||||
now = _utc_now()
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
||||
session = await unit_of_work.kyc_repository.get_active_session(user_id=user_id,now=now)
|
||||
if session is not None:
|
||||
return BeorgKycCreateResponse(
|
||||
status=True,
|
||||
link=session.link,
|
||||
user_token=session.user_token,
|
||||
client_user_token=session.client_user_token,
|
||||
qr_code=session.qr_code,
|
||||
)
|
||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
||||
|
||||
|
||||
class GetKycSessionCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> KycSessionResponse:
|
||||
now = _utc_now()
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
||||
session = await unit_of_work.kyc_repository.get_active_session(user_id=user_id,now=now)
|
||||
if session is None or session.expires_at is None:
|
||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
||||
|
||||
expires_in = max(int((session.expires_at - now).total_seconds()),0)
|
||||
return KycSessionResponse(
|
||||
status=session.status or 'started',
|
||||
link=session.link,
|
||||
qr_code=session.qr_code,
|
||||
user_token=session.user_token,
|
||||
expires_at=session.expires_at,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
7
src/application/contracts/__init__.py
Normal file
7
src/application/contracts/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from src.application.contracts.i_logger import ILogger
|
||||
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_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_beorg_service import IBeorgService
|
||||
14
src/application/contracts/i_beorg_service.py
Normal file
14
src/application/contracts/i_beorg_service.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse
|
||||
|
||||
|
||||
class IBeorgService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_identification(self,client_user_token: str) -> BeorgKycCreateResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_result(self,user_token: str) -> BeorgKycResultResponse:
|
||||
raise NotImplementedError
|
||||
34
src/application/contracts/i_cache.py
Normal file
34
src/application/contracts/i_cache.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
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 hget(self, key: str, field: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, user_id: str) -> dict | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None:
|
||||
raise NotImplementedError
|
||||
23
src/application/contracts/i_csrf_service.py
Normal file
23
src/application/contracts/i_csrf_service.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional, Mapping
|
||||
|
||||
|
||||
class ICsrfService(ABC):
|
||||
|
||||
@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
|
||||
13
src/application/contracts/i_hash_service.py
Normal file
13
src/application/contracts/i_hash_service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from abc import ABC,abstractmethod
|
||||
|
||||
|
||||
class IHashService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def hash(self,value: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def verify(self,value: str,hashed_value: str) -> bool:
|
||||
raise NotImplementedError
|
||||
10
src/application/contracts/i_jwt_service.py
Normal file
10
src/application/contracts/i_jwt_service.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.dto import AccessTokenPayload
|
||||
|
||||
|
||||
class IJwtService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def decode_access_token(self, token: str) -> AccessTokenPayload:
|
||||
raise NotImplementedError
|
||||
68
src/application/contracts/i_logger.py
Normal file
68
src/application/contracts/i_logger.py
Normal file
@@ -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"""
|
||||
...
|
||||
40
src/application/contracts/i_queue_messanger.py
Normal file
40
src/application/contracts/i_queue_messanger.py
Normal file
@@ -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
|
||||
3
src/application/domain/dto/__init__.py
Normal file
3
src/application/domain/dto/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from src.application.domain.dto.token import AccessTokenPayload, AuthContext
|
||||
from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet
|
||||
from src.application.domain.dto.beorg import BeorgKycCreateResponse,BeorgKycResultResponse,KycPersonalData,KycSessionResponse
|
||||
37
src/application/domain/dto/beorg.py
Normal file
37
src/application/domain/dto/beorg.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BeorgKycCreateResponse(BaseModel):
|
||||
status: bool
|
||||
error: str | None = None
|
||||
link: str | None = None
|
||||
user_token: str | None = None
|
||||
client_user_token: str | None = None
|
||||
qr_code: str | None = None
|
||||
|
||||
|
||||
class BeorgKycResultResponse(BaseModel):
|
||||
done_state: bool | None = None
|
||||
user_token: str
|
||||
client_user_token: str | None = None
|
||||
set_id: str | None = None
|
||||
data: Any = None
|
||||
|
||||
|
||||
class KycPersonalData(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
birth_date: str
|
||||
middle_name: str | None = None
|
||||
inn: str | None = None
|
||||
|
||||
|
||||
class KycSessionResponse(BaseModel):
|
||||
status: str
|
||||
link: str | None = None
|
||||
qr_code: str | None = None
|
||||
user_token: str | None = None
|
||||
expires_at: datetime
|
||||
expires_in: int
|
||||
20
src/application/domain/dto/keys.py
Normal file
20
src/application/domain/dto/keys.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKey:
|
||||
kid: str
|
||||
public_key_pem: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKeySet:
|
||||
active: JwtPublicKey
|
||||
previous: Optional[JwtPublicKey] = 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
|
||||
18
src/application/domain/dto/token.py
Normal file
18
src/application/domain/dto/token.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
type: str
|
||||
sid: str
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
iss: str | None = None
|
||||
aud: str | None = None
|
||||
|
||||
|
||||
class AuthContext(BaseModel):
|
||||
user_id: str
|
||||
sid: str
|
||||
token: AccessTokenPayload
|
||||
5
src/application/domain/entities/__init__.py
Normal file
5
src/application/domain/entities/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.application.domain.entities.kyc import KycEntity
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
__all__ = ['KycEntity','UserEntity']
|
||||
23
src/application/domain/entities/kyc.py
Normal file
23
src/application/domain/entities/kyc.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KycEntity:
|
||||
id: str | None = None
|
||||
user_id: str | None = None
|
||||
user_token: str | None = None
|
||||
client_user_token: str | None = None
|
||||
link: str | None = None
|
||||
qr_code: str | None = None
|
||||
status: str | None = None
|
||||
done_state: bool | None = None
|
||||
set_id: str | None = None
|
||||
error: str | None = None
|
||||
result_data: Any = None
|
||||
expires_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
30
src/application/domain/entities/user.py
Normal file
30
src/application/domain/entities/user.py
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
|
||||
crypto_wallet: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
bik: str | None = None
|
||||
account_number: str | None = None
|
||||
card_number: str | None = None
|
||||
inn: 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
|
||||
2
src/application/domain/enums/__init__.py
Normal file
2
src/application/domain/enums/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
6
src/application/domain/enums/log_format.py
Normal file
6
src/application/domain/enums/log_format.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogFormat(str,Enum):
|
||||
JSON = 'json'
|
||||
TEXT = 'text'
|
||||
10
src/application/domain/enums/log_level.py
Normal file
10
src/application/domain/enums/log_level.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class LogLevel(IntEnum):
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
CRITICAL = 50
|
||||
EXCEPTION = 60
|
||||
1
src/application/domain/exceptions/__init__.py
Normal file
1
src/application/domain/exceptions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||
18
src/application/domain/exceptions/application_exceptions.py
Normal file
18
src/application/domain/exceptions/application_exceptions.py
Normal file
@@ -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}'
|
||||
1
src/application/services/__init__.py
Normal file
1
src/application/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.services.kyc_personal_data import ensure_adult,extract_personal_data,parse_birth_date
|
||||
78
src/application/services/kyc_personal_data.py
Normal file
78
src/application/services/kyc_personal_data.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date,datetime
|
||||
from typing import Any
|
||||
from src.application.domain.dto import KycPersonalData
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
FIELD_ALIASES = {
|
||||
'first_name': {'first_name','name','given_name','имя'},
|
||||
'last_name': {'last_name','surname','family_name','фамилия'},
|
||||
'middle_name': {'middle_name','patronymic','отчество'},
|
||||
'birth_date': {'birth_date','birthdate','date_birth','birthday','дата рождения'},
|
||||
'inn': {'inn','tax_id','инн'},
|
||||
}
|
||||
|
||||
|
||||
def extract_personal_data(data: Any) -> KycPersonalData:
|
||||
values: dict[str,str] = {}
|
||||
for key,value in _walk(data):
|
||||
normalized = _normalize_key(key)
|
||||
for field,aliases in FIELD_ALIASES.items():
|
||||
if field not in values and normalized in aliases and value not in (None,''):
|
||||
values[field] = str(value).strip()
|
||||
|
||||
missing = [field for field in ('first_name','last_name','birth_date') if not values.get(field)]
|
||||
if missing:
|
||||
raise ApplicationException(status_code=422,message='KYC personal data is incomplete')
|
||||
|
||||
return KycPersonalData(
|
||||
first_name=values['first_name'],
|
||||
last_name=values['last_name'],
|
||||
middle_name=values.get('middle_name'),
|
||||
birth_date=str(_parse_date(values['birth_date'])),
|
||||
inn=values.get('inn'),
|
||||
)
|
||||
|
||||
|
||||
def ensure_adult(birth_date: date) -> None:
|
||||
today = date.today()
|
||||
try:
|
||||
adult_from = date(today.year - 18,today.month,today.day)
|
||||
except ValueError:
|
||||
adult_from = date(today.year - 18,2,28)
|
||||
if birth_date > adult_from:
|
||||
raise ApplicationException(status_code=403,message='KYC is unavailable for users under 18')
|
||||
|
||||
|
||||
def parse_birth_date(value: str) -> date:
|
||||
return _parse_date(value)
|
||||
|
||||
|
||||
def _walk(data: Any) -> list[tuple[str,Any]]:
|
||||
items: list[tuple[str,Any]] = []
|
||||
if isinstance(data,dict):
|
||||
for key,value in data.items():
|
||||
if isinstance(value,dict | list):
|
||||
items.extend(_walk(value))
|
||||
else:
|
||||
items.append((str(key),value))
|
||||
elif isinstance(data,list):
|
||||
for item in data:
|
||||
items.extend(_walk(item))
|
||||
return items
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
return key.strip().lower().replace('-','_').replace(' ','_')
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
clean = value.strip()
|
||||
formats = ('%Y-%m-%d','%d.%m.%Y','%d-%m-%Y','%d/%m/%Y','%Y.%m.%d')
|
||||
for date_format in formats:
|
||||
try:
|
||||
return datetime.strptime(clean,date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ApplicationException(status_code=422,message='KYC birth date has invalid format')
|
||||
1
src/infrastructure/beorg/__init__.py
Normal file
1
src/infrastructure/beorg/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.beorg.client import BeorgService
|
||||
83
src/infrastructure/beorg/client.py
Normal file
83
src/infrastructure/beorg/client.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import aiohttp
|
||||
from src.application.contracts import IBeorgService
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
class BeorgService(IBeorgService):
|
||||
BASE_URL = 'https://webapp.beorg.ru'
|
||||
CREATE_ENDPOINT = '/kyc/create'
|
||||
GET_RESULT_ENDPOINT = '/kyc/get_result'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
project_id: str,
|
||||
machine_uid: str,
|
||||
token: str,
|
||||
process_info: list[dict[str,Any]],
|
||||
timeout: int = 15,
|
||||
) -> None:
|
||||
self._project_id = project_id
|
||||
self._machine_uid = machine_uid
|
||||
self._token = token
|
||||
self._process_info = process_info
|
||||
self._expires = 3600
|
||||
self._timeout = timeout
|
||||
|
||||
|
||||
async def create_identification(self,client_user_token: str) -> BeorgKycCreateResponse:
|
||||
self._ensure_configured()
|
||||
payload: dict[str,Any] = {
|
||||
'project_id': self._project_id,
|
||||
'machine_uid': self._machine_uid,
|
||||
'token': self._token,
|
||||
'process_info': self._process_info,
|
||||
'client_user_token': client_user_token,
|
||||
'expires': self._expires,
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
f'{self.BASE_URL}{self.CREATE_ENDPOINT}',
|
||||
json=payload,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status >= 500:
|
||||
raise ApplicationException(status_code=502,message='Beorg service unavailable')
|
||||
|
||||
result = BeorgKycCreateResponse.model_validate(data)
|
||||
if not result.status:
|
||||
raise ApplicationException(status_code=400,message=result.error or 'Beorg rejected kyc request')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_result(self,user_token: str) -> BeorgKycResultResponse:
|
||||
self._ensure_configured()
|
||||
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(
|
||||
f'{self.BASE_URL}{self.GET_RESULT_ENDPOINT}',
|
||||
params={
|
||||
'token': self._token,
|
||||
'user_token': user_token,
|
||||
},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
|
||||
if response.status >= 500:
|
||||
raise ApplicationException(status_code=502,message='Beorg service unavailable')
|
||||
|
||||
return BeorgKycResultResponse.model_validate(data)
|
||||
|
||||
|
||||
def _ensure_configured(self) -> None:
|
||||
if not self._project_id or not self._machine_uid or not self._token or not self._process_info:
|
||||
raise ApplicationException(status_code=500,message='Beorg service is not configured')
|
||||
5
src/infrastructure/cache/__init__.py
vendored
Normal file
5
src/infrastructure/cache/__init__.py
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.infrastructure.cache.client import create_redis_client
|
||||
from src.infrastructure.cache.keydb_client import KeydbCache
|
||||
from src.infrastructure.cache.remote_cache import RemoteCache
|
||||
|
||||
__all__ = ['create_redis_client', 'KeydbCache', 'RemoteCache']
|
||||
5
src/infrastructure/cache/client.py
vendored
Normal file
5
src/infrastructure/cache/client.py
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
from redis.asyncio import Redis
|
||||
|
||||
|
||||
def create_redis_client(url: str) -> Redis:
|
||||
return Redis.from_url(url,decode_responses=True)
|
||||
55
src/infrastructure/cache/keydb_client.py
vendored
Normal file
55
src/infrastructure/cache/keydb_client.py
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
import orjson
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ICache
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
class KeydbCache(ICache):
|
||||
USER_PREFIX = 'user:me'
|
||||
|
||||
def __init__(self, redis_client: Redis):
|
||||
self._r = redis_client
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int) -> bool:
|
||||
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 hget(self, key: str, field: str) -> str | None:
|
||||
return await self._r.hget(key, field)
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
return (await self._r.delete(key)) > 0
|
||||
|
||||
async def get_user(self, user_id: str) -> dict | None:
|
||||
raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}')
|
||||
if raw is None:
|
||||
return None
|
||||
return orjson.loads(raw)
|
||||
|
||||
async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None:
|
||||
data = orjson.dumps({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'middle_name': user.middle_name,
|
||||
'last_name': user.last_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'crypto_wallet': user.crypto_wallet,
|
||||
'phone': user.phone,
|
||||
'bik': user.bik,
|
||||
'account_number': user.account_number,
|
||||
'card_number': user.card_number,
|
||||
'inn': user.inn,
|
||||
'kyc_verified': user.kyc_verified,
|
||||
'is_deleted': user.is_deleted,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
})
|
||||
await self._r.set(f'{self.USER_PREFIX}:{user_id}', data, ex=ttl)
|
||||
65
src/infrastructure/cache/remote_cache.py
vendored
Normal file
65
src/infrastructure/cache/remote_cache.py
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
import orjson
|
||||
from redis.asyncio.client import Redis
|
||||
from src.application.contracts import ICache
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
class RemoteCache(ICache):
|
||||
|
||||
|
||||
USER_PREFIX = 'user:me'
|
||||
|
||||
|
||||
def __init__(self,redis_client: Redis) -> None:
|
||||
self._r = redis_client
|
||||
|
||||
|
||||
async def set(self,key: str,value: str,ttl: int) -> bool:
|
||||
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 hget(self,key: str,field: str) -> str | None:
|
||||
return await self._r.hget(key,field)
|
||||
|
||||
|
||||
async def delete(self,key: str) -> bool:
|
||||
return (await self._r.delete(key)) > 0
|
||||
|
||||
|
||||
async def get_user(self,user_id: str) -> dict | None:
|
||||
raw = await self._r.get(f'{self.USER_PREFIX}:{user_id}')
|
||||
if raw is None:
|
||||
return None
|
||||
return orjson.loads(raw)
|
||||
|
||||
|
||||
async def set_user(self,user_id: str,user: UserEntity,ttl: int = 300) -> None:
|
||||
data = orjson.dumps({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'first_name': user.first_name,
|
||||
'middle_name': user.middle_name,
|
||||
'last_name': user.last_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'crypto_wallet': user.crypto_wallet,
|
||||
'phone': user.phone,
|
||||
'bik': user.bik,
|
||||
'account_number': user.account_number,
|
||||
'card_number': user.card_number,
|
||||
'inn': user.inn,
|
||||
'kyc_verified': user.kyc_verified,
|
||||
'is_deleted': user.is_deleted,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'updated_at': user.updated_at.isoformat() if user.updated_at else None,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
})
|
||||
await self._r.set(f'{self.USER_PREFIX}:{user_id}',data,ex=ttl)
|
||||
1
src/infrastructure/config/__init__.py
Normal file
1
src/infrastructure/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.config.settings import Settings,get_settings,settings
|
||||
132
src/infrastructure/config/settings.py
Normal file
132
src/infrastructure/config/settings.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from functools import lru_cache
|
||||
import json
|
||||
from typing import Any
|
||||
from pydantic import Field,PrivateAttr
|
||||
from pydantic_settings import BaseSettings,SettingsConfigDict
|
||||
from src.infrastructure.vault import VaultClient
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file='.env',extra='ignore')
|
||||
_vault_beorg_secrets: dict[str,Any] = PrivateAttr(default_factory=dict)
|
||||
|
||||
DOCS_USERNAME: str = 'admin'
|
||||
DOCS_PASSWORD: str = 'admin'
|
||||
KEYDB_URL: str = 'redis://localhost:6379/0'
|
||||
VAULT_ADDR: str = 'https://corp.vault.elcsa.ru'
|
||||
VAULT_ROLE_ID: str = ''
|
||||
VAULT_SECRET_ID: str = ''
|
||||
VAULT_NAMESPACE: str | None = None
|
||||
VAULT_MOUNT_POINT: str = 'dev-secrets'
|
||||
VAULT_APP_SECRET_PATH: str = 'app'
|
||||
VAULT_BEORG_SECRET_PATH: str = 'beorg'
|
||||
VAULT_DATABASE_SECRET_PATH: str = 'database'
|
||||
VAULT_JWT_SECRET_PATH: str = 'jwt'
|
||||
VAULT_RABBIT_SECRET_PATH: str = 'rabbitmq'
|
||||
VAULT_DOCS_SECRET_PATH: str = 'docs'
|
||||
VAULT_JWT_KID_PATH: str = 'jwt/kid'
|
||||
VAULT_JWT_KIDS_PREFIX: str = 'jwt/kids'
|
||||
JWT_KEYS_REFRESH_SECONDS: int = 300
|
||||
JWT_ALGORITHM: str = 'RS256'
|
||||
JWT_AUDIENCE: str | None = None
|
||||
JWT_ISSUER: str | None = None
|
||||
RABBIT_URL: str = 'amqp://guest:guest@localhost:5672/'
|
||||
RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE: str = 'crypto_transfer_completed'
|
||||
RABBIT_KYC_VERIFIED_QUEUE: str = 'kyc_verified'
|
||||
RABBIT_PUBLISH_PERSIST: bool = True
|
||||
DATABASE_URL: str = 'postgresql+asyncpg://postgres:postgres@localhost:5432/kyc'
|
||||
DATABASE_POOL_SIZE: int = 5
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
DATABASE_POOL_RECYCLE: int = 1800
|
||||
DATABASE_ECHO: bool = False
|
||||
EXCLUDED_PATHS: tuple[str,...] = ('/docs','/redoc','/openapi.json','/ping')
|
||||
BEORG_TIMEOUT: int = 15
|
||||
BEORG_PROCESS_INFO: list[dict[str,Any]] = Field(default_factory=lambda: [
|
||||
{
|
||||
'key': 'SELFIE1',
|
||||
'type': 'SELFIE',
|
||||
'options': {
|
||||
'stages': [
|
||||
'biometry_liveness',
|
||||
],
|
||||
},
|
||||
'attempts': 3,
|
||||
},
|
||||
{
|
||||
'key': 'PASSPORT1',
|
||||
'type': 'PASSPORT',
|
||||
'options': {
|
||||
'stages': [
|
||||
'verification',
|
||||
'biometry_match',
|
||||
],
|
||||
'relation': {
|
||||
'biometry_match': 'SELFIE1',
|
||||
},
|
||||
},
|
||||
'attempts': 3,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@property
|
||||
def BEORG_PROJECT_ID(self) -> str:
|
||||
return self._get_beorg_secret('project_id','BEORG_PROJECT_ID')
|
||||
|
||||
|
||||
@property
|
||||
def BEORG_MACHINE_UID(self) -> str:
|
||||
return self._get_beorg_secret('machine_uid','BEORG_MACHINE_UID')
|
||||
|
||||
|
||||
@property
|
||||
def BEORG_TOKEN(self) -> str:
|
||||
return self._get_beorg_secret('token','BEORG_TOKEN')
|
||||
|
||||
|
||||
def _get_beorg_secret(self,*keys: str) -> str:
|
||||
for key in keys:
|
||||
value = self._vault_beorg_secrets.get(key)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
return ''
|
||||
|
||||
|
||||
def model_post_init(self,__context: Any) -> None:
|
||||
if not self.VAULT_ROLE_ID or not self.VAULT_SECRET_ID:
|
||||
return
|
||||
|
||||
client = VaultClient(
|
||||
addr=self.VAULT_ADDR,
|
||||
role_id=self.VAULT_ROLE_ID,
|
||||
secret_id=self.VAULT_SECRET_ID,
|
||||
namespace=self.VAULT_NAMESPACE,
|
||||
mount_point=self.VAULT_MOUNT_POINT,
|
||||
)
|
||||
object.__setattr__(self,'_vault_beorg_secrets',client.read_many(self.VAULT_BEORG_SECRET_PATH))
|
||||
secrets = client.read_many(
|
||||
self.VAULT_APP_SECRET_PATH,
|
||||
self.VAULT_BEORG_SECRET_PATH,
|
||||
self.VAULT_DATABASE_SECRET_PATH,
|
||||
self.VAULT_JWT_SECRET_PATH,
|
||||
self.VAULT_RABBIT_SECRET_PATH,
|
||||
self.VAULT_DOCS_SECRET_PATH,
|
||||
)
|
||||
for field in type(self).model_fields:
|
||||
if field.startswith('VAULT_') or field == 'KEYDB_URL':
|
||||
continue
|
||||
value = secrets.get(field,secrets.get(field.lower()))
|
||||
if value is None:
|
||||
continue
|
||||
if field == 'BEORG_PROCESS_INFO' and isinstance(value,str):
|
||||
value = json.loads(value)
|
||||
object.__setattr__(self,field,value)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
4
src/infrastructure/context_vars/__init__.py
Normal file
4
src/infrastructure/context_vars/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
trace_id_var: ContextVar[str] = ContextVar('trace_id',default='N/A')
|
||||
0
src/infrastructure/context_vars/trace_id.py
Normal file
0
src/infrastructure/context_vars/trace_id.py
Normal file
1
src/infrastructure/database/__init__.py
Normal file
1
src/infrastructure/database/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.database.unit_of_work import UnitOfWork
|
||||
22
src/infrastructure/database/context.py
Normal file
22
src/infrastructure/database/context.py
Normal file
@@ -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
|
||||
0
src/infrastructure/database/decorators/__init__.py
Normal file
0
src/infrastructure/database/decorators/__init__.py
Normal file
5
src/infrastructure/database/models/__init__.py
Normal file
5
src/infrastructure/database/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.infrastructure.database.models.base import Base
|
||||
from src.infrastructure.database.models.user import UserModel
|
||||
from src.infrastructure.database.models.kyc import KycModel
|
||||
|
||||
__all__ = ['Base','UserModel','KycModel']
|
||||
5
src/infrastructure/database/models/base.py
Normal file
5
src/infrastructure/database/models/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
24
src/infrastructure/database/models/kyc.py
Normal file
24
src/infrastructure/database/models/kyc.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from sqlalchemy import Boolean,DateTime,ForeignKey,JSON,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,SoftDeleteMixin,UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
class KycModel(Base,UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin):
|
||||
__tablename__ = 'kyc'
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(26),ForeignKey('users.id',ondelete='CASCADE'),nullable=False,index=True)
|
||||
user_token: Mapped[str | None] = mapped_column(String(255),nullable=True,index=True)
|
||||
client_user_token: Mapped[str | None] = mapped_column(String(255),nullable=True,index=True)
|
||||
link: Mapped[str | None] = mapped_column(Text,nullable=True)
|
||||
qr_code: Mapped[str | None] = mapped_column(Text,nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32),nullable=False,server_default='started',default='started',index=True)
|
||||
done_state: Mapped[bool | None] = mapped_column(Boolean,nullable=True)
|
||||
set_id: Mapped[str | None] = mapped_column(String(255),nullable=True,index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text,nullable=True)
|
||||
result_data: Mapped[Any | None] = mapped_column(JSON,nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),nullable=False,index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True),nullable=True)
|
||||
3
src/infrastructure/database/models/mixins/__init__.py
Normal file
3
src/infrastructure/database/models/mixins/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from src.infrastructure.database.models.mixins.audit import AuditTimestampsMixin
|
||||
from src.infrastructure.database.models.mixins.soft_delete import SoftDeleteMixin
|
||||
from src.infrastructure.database.models.mixins.ulid import UlidPrimaryKeyMixin
|
||||
12
src/infrastructure/database/models/mixins/audit.py
Normal file
12
src/infrastructure/database/models/mixins/audit.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from datetime import datetime,timezone
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.orm import Mapped,mapped_column
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class AuditTimestampsMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),default=_utc_now,nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),default=_utc_now,onupdate=_utc_now,nullable=False)
|
||||
6
src/infrastructure/database/models/mixins/soft_delete.py
Normal file
6
src/infrastructure/database/models/mixins/soft_delete.py
Normal file
@@ -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)
|
||||
7
src/infrastructure/database/models/mixins/ulid.py
Normal file
7
src/infrastructure/database/models/mixins/ulid.py
Normal file
@@ -0,0 +1,7 @@
|
||||
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()))
|
||||
29
src/infrastructure/database/models/user.py
Normal file
29
src/infrastructure/database/models/user.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Date, 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, SoftDeleteMixin, UlidPrimaryKeyMixin
|
||||
|
||||
|
||||
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)
|
||||
|
||||
crypto_wallet: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
bik: Mapped[str | None] = mapped_column(String(9), nullable=True)
|
||||
account_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
card_number: Mapped[str | None] = mapped_column(String(19), nullable=True)
|
||||
inn: Mapped[str | None] = mapped_column(String(12), 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)
|
||||
2
src/infrastructure/database/repositories/__init__.py
Normal file
2
src/infrastructure/database/repositories/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.infrastructure.database.repositories.kyc_repository import KycRepository
|
||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||
120
src/infrastructure/database/repositories/kyc_repository.py
Normal file
120
src/infrastructure/database/repositories/kyc_repository.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timezone
|
||||
from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.application.abstractions.repositories import IKycRepository
|
||||
from src.application.domain.entities import KycEntity
|
||||
from src.infrastructure.database.models.kyc import KycModel
|
||||
|
||||
|
||||
class KycRepository(IKycRepository):
|
||||
|
||||
def __init__(self,session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
|
||||
async def create_started_session(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str | None,
|
||||
client_user_token: str | None,
|
||||
link: str | None,
|
||||
qr_code: str | None,
|
||||
expires_at: datetime,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
kyc = KycModel(
|
||||
user_id=user_id,
|
||||
user_token=user_token,
|
||||
client_user_token=client_user_token,
|
||||
link=link,
|
||||
qr_code=qr_code,
|
||||
status='started',
|
||||
expires_at=expires_at,
|
||||
error=error,
|
||||
)
|
||||
self._session.add(kyc)
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
async def update_session_result(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str,
|
||||
status: str,
|
||||
done_state: bool | None,
|
||||
set_id: str | None,
|
||||
result_data: Any,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
result = await self._session.execute(
|
||||
select(KycModel)
|
||||
.where(KycModel.user_id == user_id,KycModel.user_token == user_token)
|
||||
.order_by(KycModel.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
kyc = result.scalar_one_or_none()
|
||||
if kyc is None:
|
||||
return
|
||||
|
||||
kyc.status = status
|
||||
kyc.done_state = done_state
|
||||
kyc.set_id = set_id
|
||||
kyc.result_data = result_data
|
||||
kyc.error = error
|
||||
kyc.completed_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
async def expire_started_sessions(self,*,user_id: str,now: datetime) -> None:
|
||||
result = await self._session.execute(
|
||||
select(KycModel)
|
||||
.where(
|
||||
KycModel.user_id == user_id,
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at <= now,
|
||||
)
|
||||
)
|
||||
for kyc in result.scalars():
|
||||
kyc.status = 'expired'
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
async def get_active_session(self,*,user_id: str,now: datetime) -> KycEntity | None:
|
||||
result = await self._session.execute(
|
||||
select(KycModel)
|
||||
.where(
|
||||
KycModel.user_id == user_id,
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at > now,
|
||||
)
|
||||
.order_by(KycModel.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
kyc = result.scalar_one_or_none()
|
||||
if kyc is None:
|
||||
return None
|
||||
return self._to_entity(kyc)
|
||||
|
||||
|
||||
def _to_entity(self,kyc: KycModel) -> KycEntity:
|
||||
return KycEntity(
|
||||
id=kyc.id,
|
||||
user_id=kyc.user_id,
|
||||
user_token=kyc.user_token,
|
||||
client_user_token=kyc.client_user_token,
|
||||
link=kyc.link,
|
||||
qr_code=kyc.qr_code,
|
||||
status=kyc.status,
|
||||
done_state=kyc.done_state,
|
||||
set_id=kyc.set_id,
|
||||
error=kyc.error,
|
||||
result_data=kyc.result_data,
|
||||
expires_at=kyc.expires_at,
|
||||
completed_at=kyc.completed_at,
|
||||
created_at=kyc.created_at,
|
||||
updated_at=kyc.updated_at,
|
||||
)
|
||||
82
src/infrastructure/database/repositories/user_repository.py
Normal file
82
src/infrastructure/database/repositories/user_repository.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date,datetime,timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.application.abstractions.repositories import IUserRepository
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.models.user import UserModel
|
||||
|
||||
|
||||
class UserRepository(IUserRepository):
|
||||
|
||||
def __init__(self,session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
|
||||
async def create_user(self,email: str,password_hash: str) -> UserEntity:
|
||||
user = UserModel(email=email,password_hash=password_hash)
|
||||
self._session.add(user)
|
||||
await self._session.flush()
|
||||
return self._to_entity(user)
|
||||
|
||||
|
||||
async def get_user_by_email(self,email: str) -> UserEntity:
|
||||
result = await self._session.execute(select(UserModel).where(UserModel.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise ApplicationException(status_code=404,message='User not found')
|
||||
return self._to_entity(user)
|
||||
|
||||
|
||||
async def exists_by_email(self,email: str) -> bool:
|
||||
result = await self._session.execute(select(UserModel.id).where(UserModel.email == email))
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def update_kyc_data(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
birth_date: date,
|
||||
middle_name: str | None,
|
||||
inn: str | None,
|
||||
) -> UserEntity:
|
||||
user = await self._session.get(UserModel,user_id)
|
||||
if user is None:
|
||||
raise ApplicationException(status_code=404,message='User not found')
|
||||
|
||||
user.first_name = first_name
|
||||
user.last_name = last_name
|
||||
user.middle_name = middle_name
|
||||
user.birth_date = birth_date
|
||||
user.inn = inn
|
||||
user.kyc_verified = True
|
||||
user.kyc_verified_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
return self._to_entity(user)
|
||||
|
||||
|
||||
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,
|
||||
crypto_wallet=user.crypto_wallet,
|
||||
phone=user.phone,
|
||||
bik=user.bik,
|
||||
account_number=user.account_number,
|
||||
card_number=user.card_number,
|
||||
inn=user.inn,
|
||||
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,
|
||||
)
|
||||
62
src/infrastructure/database/unit_of_work.py
Normal file
62
src/infrastructure/database/unit_of_work.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession,async_sessionmaker
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.abstractions.repositories import IKycRepository,IUserRepository
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.database.repositories import KycRepository,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._kyc_repository: IKycRepository | None = None
|
||||
self._logger: ILogger = logger
|
||||
|
||||
async def __aenter__(self):
|
||||
self._session = self.session_factory()
|
||||
self._user_repository = None
|
||||
self._kyc_repository = None
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self._session is None:
|
||||
return
|
||||
if exc_type:
|
||||
self._logger.error(str(exc_val))
|
||||
await self._session.rollback()
|
||||
self._logger.error(f'Rollback: str{exc_val})')
|
||||
else:
|
||||
await self._session.flush()
|
||||
await self._session.commit()
|
||||
self._logger.debug('Commit')
|
||||
await self._session.close()
|
||||
|
||||
|
||||
async def commit(self) -> None:
|
||||
if self._session is not None:
|
||||
await self._session.commit()
|
||||
|
||||
|
||||
async def rollback(self) -> None:
|
||||
if self._session is not None:
|
||||
await self._session.rollback()
|
||||
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository:
|
||||
if self._session is None:
|
||||
raise RuntimeError('UnitOfWork session is not initialized')
|
||||
if self._user_repository is None:
|
||||
self._user_repository = UserRepository(session=self._session)
|
||||
return self._user_repository
|
||||
|
||||
|
||||
@property
|
||||
def kyc_repository(self) -> IKycRepository:
|
||||
if self._session is None:
|
||||
raise RuntimeError('UnitOfWork session is not initialized')
|
||||
if self._kyc_repository is None:
|
||||
self._kyc_repository = KycRepository(session=self._session)
|
||||
return self._kyc_repository
|
||||
4
src/infrastructure/logger/__init__.py
Normal file
4
src/infrastructure/logger/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from src.infrastructure.logger.logger import Logger
|
||||
|
||||
|
||||
logger = Logger()
|
||||
129
src/infrastructure/logger/logger.py
Normal file
129
src/infrastructure/logger/logger.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import traceback
|
||||
import inspect
|
||||
import sys
|
||||
import orjson
|
||||
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 = orjson.dumps(log_data).decode()
|
||||
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)
|
||||
1
src/infrastructure/messanger/__init__.py
Normal file
1
src/infrastructure/messanger/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.infrastructure.messanger.rabbit_client import RabbitClient
|
||||
72
src/infrastructure/messanger/rabbit_client.py
Normal file
72
src/infrastructure/messanger/rabbit_client.py
Normal file
@@ -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,
|
||||
)
|
||||
0
src/infrastructure/security/__init__.py
Normal file
0
src/infrastructure/security/__init__.py
Normal file
0
src/infrastructure/security/csrf.py
Normal file
0
src/infrastructure/security/csrf.py
Normal file
0
src/infrastructure/security/hash.py
Normal file
0
src/infrastructure/security/hash.py
Normal file
109
src/infrastructure/security/jwt.py
Normal file
109
src/infrastructure/security/jwt.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
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
|
||||
|
||||
async def decode_access_token(self, token: str) -> AccessTokenPayload:
|
||||
payload = await self._decode_and_verify(token)
|
||||
|
||||
if payload.get('type') != 'access':
|
||||
self._logger.warning(f'Access token invalid type received_type={payload.get("type")}')
|
||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
||||
|
||||
try:
|
||||
return AccessTokenPayload(
|
||||
sub=str(payload['sub']),
|
||||
type='access',
|
||||
sid=str(payload['sid']),
|
||||
iat=int(payload['iat']),
|
||||
nbf=int(payload['nbf']),
|
||||
exp=int(payload['exp']),
|
||||
iss=payload.get('iss'),
|
||||
aud=payload.get('aud'),
|
||||
)
|
||||
except KeyError as exception:
|
||||
self._logger.warning(f'Access token missing claim error={str(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:
|
||||
self._logger.warning(f'JWT header missing kid header={header}')
|
||||
raise ApplicationException(status_code=401, message='Missing token header: kid')
|
||||
|
||||
received_alg = header.get('alg')
|
||||
if received_alg != settings.JWT_ALGORITHM:
|
||||
self._logger.warning(f'JWT invalid algorithm kid={kid} received_alg={received_alg} expected_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:
|
||||
self._logger.info(f'JWT kid miss kid={kid} forcing keystore refresh')
|
||||
await self._key_store.refresh()
|
||||
public_pem = await self._key_store.get_public_key_for_kid(str(kid))
|
||||
|
||||
if not public_pem:
|
||||
self._logger.warning(f'JWT unknown kid kid={kid}')
|
||||
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(settings.JWT_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=settings.JWT_ISSUER or None,
|
||||
options=options,
|
||||
)
|
||||
|
||||
if 'sid' not in payload:
|
||||
self._logger.warning(f'JWT missing sid claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: sid')
|
||||
|
||||
if 'type' not in payload:
|
||||
self._logger.warning(f'JWT missing type claim kid={kid}')
|
||||
raise ApplicationException(status_code=401, message='Missing token claim: type')
|
||||
|
||||
return payload
|
||||
|
||||
except ExpiredSignatureError as exception:
|
||||
self._logger.info(f'JWT expired kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=401, message='Token expired')
|
||||
|
||||
except ApplicationException:
|
||||
raise
|
||||
|
||||
except JWTError as exception:
|
||||
self._logger.warning(f'JWT decode failed kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=401, message='Invalid token')
|
||||
|
||||
except Exception as exception:
|
||||
self._logger.error(f'Unexpected JWT decode error kid={kid} error={str(exception)}')
|
||||
raise ApplicationException(status_code=500, message='JWT decode failed')
|
||||
5
src/infrastructure/utils/__init__.py
Normal file
5
src/infrastructure/utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from ulid import ULID
|
||||
|
||||
|
||||
def generate_instance_id() -> str:
|
||||
return str(ULID())
|
||||
0
src/infrastructure/utils/instance_id.py
Normal file
0
src/infrastructure/utils/instance_id.py
Normal file
101
src/infrastructure/vault/__init__.py
Normal file
101
src/infrastructure/vault/__init__.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
import hvac
|
||||
|
||||
|
||||
class VaultClient:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
addr: str,
|
||||
role_id: str,
|
||||
secret_id: str,
|
||||
namespace: str | None,
|
||||
mount_point: str,
|
||||
) -> None:
|
||||
self._mount_point = mount_point
|
||||
self._client = hvac.Client(url=addr,namespace=namespace)
|
||||
self._client.auth.approle.login(role_id=role_id,secret_id=secret_id)
|
||||
|
||||
|
||||
def read_secret(self,path: str) -> dict[str,Any]:
|
||||
secret = self._client.secrets.kv.v2.read_secret_version(
|
||||
path=path,
|
||||
mount_point=self._mount_point,
|
||||
)
|
||||
return dict(secret.get('data',{}).get('data',{}))
|
||||
|
||||
|
||||
def read_many(self,*paths: str) -> dict[str,Any]:
|
||||
result: dict[str,Any] = {}
|
||||
for path in paths:
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
result.update(self.read_secret(path))
|
||||
except hvac.exceptions.InvalidPath:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
class JwtKeyStore:
|
||||
_instance: 'JwtKeyStore | None' = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
vault_addr: str = '',
|
||||
vault_role_id: str = '',
|
||||
vault_secret_id: str = '',
|
||||
vault_namespace: str | None = None,
|
||||
mount_point: str = '',
|
||||
kid_path: str = '',
|
||||
kids_prefix: str = '',
|
||||
) -> None:
|
||||
self._keys: dict[str,str] = {}
|
||||
self._kid_path = kid_path
|
||||
self._kids_prefix = kids_prefix
|
||||
self._vault_client: VaultClient | None = None
|
||||
if vault_addr and vault_role_id and vault_secret_id:
|
||||
self._vault_client = VaultClient(
|
||||
addr=vault_addr,
|
||||
role_id=vault_role_id,
|
||||
secret_id=vault_secret_id,
|
||||
namespace=vault_namespace,
|
||||
mount_point=mount_point,
|
||||
)
|
||||
JwtKeyStore._instance = self
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'JwtKeyStore':
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
async def refresh(self) -> None:
|
||||
if self._vault_client is None:
|
||||
return None
|
||||
|
||||
current = self._vault_client.read_secret(self._kid_path)
|
||||
kid = current.get('kid')
|
||||
if kid:
|
||||
key_data = self._vault_client.read_secret(f'{self._kids_prefix}/{kid}')
|
||||
public_key = key_data.get('public_key') or key_data.get('public')
|
||||
if public_key:
|
||||
self._keys[str(kid)] = str(public_key)
|
||||
return None
|
||||
|
||||
|
||||
async def get_public_key_for_kid(self,kid: str) -> str | None:
|
||||
return self._keys.get(kid)
|
||||
|
||||
|
||||
def start_jwt_keys_scheduler(jwt_store: JwtKeyStore,refresh_seconds: int) -> AsyncIOScheduler:
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(jwt_store.refresh,'interval',seconds=refresh_seconds)
|
||||
scheduler.start()
|
||||
return scheduler
|
||||
0
src/infrastructure/vault/keys.py
Normal file
0
src/infrastructure/vault/keys.py
Normal file
0
src/infrastructure/vault/scheduler.py
Normal file
0
src/infrastructure/vault/scheduler.py
Normal file
0
src/infrastructure/vault/utils.py
Normal file
0
src/infrastructure/vault/utils.py
Normal file
119
src/main.py
Normal file
119
src/main.py
Normal file
@@ -0,0 +1,119 @@
|
||||
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.responses import HTMLResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.cache import create_redis_client
|
||||
from src.infrastructure.config.settings import get_settings
|
||||
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.handlers import application_exception_handler, unhandled_exception_handler
|
||||
from src.presentation.messaging import crypto_transfer_router
|
||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
||||
from src.presentation.routing import kyc_router
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
|
||||
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'Users service instance started with id {instance_id}')
|
||||
|
||||
app.state.redis = create_redis_client(settings.KEYDB_URL)
|
||||
|
||||
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
|
||||
yield
|
||||
await app.state.redis.aclose()
|
||||
logger.info(f'Users service instance ended with id {instance_id}')
|
||||
|
||||
|
||||
app: FastAPI = FastAPI(
|
||||
redoc_url=None,
|
||||
docs_url=None,
|
||||
lifespan=lifespan,
|
||||
title='Elcsa Users Service'
|
||||
)
|
||||
|
||||
app.add_exception_handler(ApplicationException, application_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
|
||||
app.include_router(kyc_router)
|
||||
app.include_router(crypto_transfer_router)
|
||||
|
||||
|
||||
# Added middleware
|
||||
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.get('/docs', include_in_schema=False)
|
||||
async def custom_swagger_ui_html(_credentials: HTTPBasicCredentials = Depends(verify_credentials)) -> HTMLResponse:
|
||||
'''Custom Swagger documentation, optionally protected with basic authentication.'''
|
||||
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:
|
||||
'''Custom ReDoc documentation, optionally protected with basic authentication.'''
|
||||
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',
|
||||
}
|
||||
1
src/presentation/decorators/__init__.py
Normal file
1
src/presentation/decorators/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
36
src/presentation/decorators/auth.py
Normal file
36
src/presentation/decorators/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
from src.application.contracts import IJwtService
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.domain.dto import AccessTokenPayload, AuthContext
|
||||
from src.presentation.dependencies import get_jwt_service
|
||||
|
||||
|
||||
def _extract_access_token(request: Request) -> str | None:
|
||||
token = request.cookies.get('access_token')
|
||||
|
||||
if token:
|
||||
return token
|
||||
|
||||
auth = request.headers.get('Authorization')
|
||||
if auth:
|
||||
scheme, param = get_authorization_scheme_param(auth)
|
||||
if scheme.lower() == 'bearer' and param:
|
||||
return param
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def require_access_token(
|
||||
request: Request,
|
||||
jwt_service: IJwtService = Depends(get_jwt_service),
|
||||
) -> AuthContext:
|
||||
token = _extract_access_token(request)
|
||||
if not token:
|
||||
raise ApplicationException(status_code=401, message='Not authenticated')
|
||||
|
||||
payload: AccessTokenPayload = await jwt_service.decode_access_token(token)
|
||||
if payload.type != 'access':
|
||||
raise ApplicationException(status_code=401, message='Invalid token type')
|
||||
|
||||
return AuthContext(user_id=payload.sub, sid=payload.sid, token=payload)
|
||||
45
src/presentation/decorators/cache.py
Normal file
45
src/presentation/decorators/cache.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
from typing import Any,Awaitable,Callable
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.presentation.dependencies.cache import get_cache_remote
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
def cached(*, prefix: str) -> Callable:
|
||||
|
||||
|
||||
def decorator(func: Callable[..., Awaitable[Any]]):
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
logger = get_logger()
|
||||
|
||||
request = kwargs.get('request')
|
||||
if not isinstance(request, Request):
|
||||
for a in args:
|
||||
if isinstance(a, Request):
|
||||
request = a
|
||||
break
|
||||
|
||||
auth = kwargs.get('auth')
|
||||
user_id = getattr(auth, 'user_id', None) if auth else None
|
||||
|
||||
if request is None or user_id is None:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
cache_key = f'{prefix}:{user_id}'
|
||||
|
||||
try:
|
||||
cache = get_cache_remote(request)
|
||||
hit = await cache.get_user(user_id)
|
||||
if hit is not None:
|
||||
logger.debug(f'Cache hit key={cache_key}')
|
||||
return ORJSONResponse(status_code=200, content=hit)
|
||||
except Exception as e:
|
||||
logger.warning(f'Cache read failed key={cache_key} error={e}')
|
||||
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
61
src/presentation/decorators/csrf.py
Normal file
61
src/presentation/decorators/csrf.py
Normal file
@@ -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
|
||||
171
src/presentation/decorators/rate_limit.py
Normal file
171
src/presentation/decorators/rate_limit.py
Normal file
@@ -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
|
||||
1
src/presentation/dependencies/__init__.py
Normal file
1
src/presentation/dependencies/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.dependencies.security import get_jwt_service
|
||||
28
src/presentation/dependencies/cache.py
Normal file
28
src/presentation/dependencies/cache.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
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_remote(request: Request) -> Redis:
|
||||
return request.app.state.redis
|
||||
|
||||
|
||||
def get_redis(request: Request) -> Redis:
|
||||
return request.app.state.redis
|
||||
|
||||
|
||||
def get_cache_remote(redis_client: Redis = Depends(get_redis_remote)) -> ICache:
|
||||
return KeydbCache(redis_client)
|
||||
|
||||
|
||||
def get_remote_cache(redis_client: Redis = Depends(get_redis_remote)) -> ICache:
|
||||
return KeydbCache(redis_client)
|
||||
|
||||
|
||||
def get_cache(redis_client: Redis = Depends(get_redis)) -> ICache:
|
||||
return KeydbCache(redis_client)
|
||||
60
src/presentation/dependencies/commands.py
Normal file
60
src/presentation/dependencies/commands.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import CompleteKycCommand,GetKycSessionCommand,PassKycCommand
|
||||
from src.application.contracts import IBeorgService,ICache,ILogger,IQueueMessanger
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.beorg import BeorgService
|
||||
from src.presentation.dependencies.cache import get_cache
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
from src.presentation.dependencies.queue_messanger import get_rabbit
|
||||
from src.presentation.dependencies.unit_of_work import get_unit_of_work
|
||||
|
||||
|
||||
def get_beorg_service() -> IBeorgService:
|
||||
return BeorgService(
|
||||
project_id=settings.BEORG_PROJECT_ID,
|
||||
machine_uid=settings.BEORG_MACHINE_UID,
|
||||
token=settings.BEORG_TOKEN,
|
||||
process_info=settings.BEORG_PROCESS_INFO,
|
||||
timeout=settings.BEORG_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def get_pass_kyc_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
beorg_service: IBeorgService = Depends(get_beorg_service),
|
||||
) -> PassKycCommand:
|
||||
return PassKycCommand(
|
||||
unit_of_work=unit_of_work,
|
||||
logger=logger,
|
||||
cache=cache,
|
||||
beorg_service=beorg_service,
|
||||
)
|
||||
|
||||
|
||||
def get_kyc_session_command(
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
) -> GetKycSessionCommand:
|
||||
return GetKycSessionCommand(
|
||||
unit_of_work=unit_of_work,
|
||||
)
|
||||
|
||||
|
||||
def get_complete_kyc_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
beorg_service: IBeorgService = Depends(get_beorg_service),
|
||||
queue_messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
) -> CompleteKycCommand:
|
||||
return CompleteKycCommand(
|
||||
unit_of_work=unit_of_work,
|
||||
logger=logger,
|
||||
cache=cache,
|
||||
beorg_service=beorg_service,
|
||||
queue_messanger=queue_messanger,
|
||||
verified_queue=settings.RABBIT_KYC_VERIFIED_QUEUE,
|
||||
)
|
||||
7
src/presentation/dependencies/logger.py
Normal file
7
src/presentation/dependencies/logger.py
Normal file
@@ -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
|
||||
8
src/presentation/dependencies/queue_messanger.py
Normal file
8
src/presentation/dependencies/queue_messanger.py
Normal file
@@ -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()
|
||||
16
src/presentation/dependencies/security.py
Normal file
16
src/presentation/dependencies/security.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
from src.application.contracts import IJwtService,ILogger
|
||||
from src.infrastructure.security.jwt import JwtService
|
||||
from src.infrastructure.vault import JwtKeyStore
|
||||
from src.presentation.dependencies.logger import get_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)
|
||||
10
src/presentation/dependencies/unit_of_work.py
Normal file
10
src/presentation/dependencies/unit_of_work.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.database.context import async_session_maker
|
||||
from src.infrastructure.database.unit_of_work import UnitOfWork
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
def get_unit_of_work(logger: ILogger = Depends(get_logger)) -> IUnitOfWork:
|
||||
return UnitOfWork(session_factory=async_session_maker,logger=logger)
|
||||
18
src/presentation/handlers/__init__.py
Normal file
18
src/presentation/handlers/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
async def application_exception_handler(request: Request,exception: ApplicationException) -> ORJSONResponse:
|
||||
return ORJSONResponse(
|
||||
status_code=exception.status_code,
|
||||
content={'detail': exception.message},
|
||||
headers=exception.headers,
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request,exception: Exception) -> ORJSONResponse:
|
||||
return ORJSONResponse(
|
||||
status_code=500,
|
||||
content={'detail': 'Internal server error'},
|
||||
)
|
||||
0
src/presentation/handlers/application_handler.py
Normal file
0
src/presentation/handlers/application_handler.py
Normal file
0
src/presentation/handlers/unhandled_handler.py
Normal file
0
src/presentation/handlers/unhandled_handler.py
Normal file
1
src/presentation/messaging/__init__.py
Normal file
1
src/presentation/messaging/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.messaging.crypto_transfer import crypto_transfer_router
|
||||
39
src/presentation/messaging/crypto_transfer.py
Normal file
39
src/presentation/messaging/crypto_transfer.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import Depends
|
||||
import orjson
|
||||
from faststream.rabbit.fastapi import RabbitMessage,RabbitRouter
|
||||
from pydantic import BaseModel
|
||||
from src.application.contracts import ILogger
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
|
||||
|
||||
crypto_transfer_router=RabbitRouter(settings.RABBIT_URL)
|
||||
|
||||
|
||||
class CryptoTransferCompletedMessage(BaseModel):
|
||||
user_id: str
|
||||
order_id: str
|
||||
trace_id: str
|
||||
message_id: str
|
||||
|
||||
|
||||
@crypto_transfer_router.subscriber(settings.RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE)
|
||||
async def crypto_transfer_completed_handler(
|
||||
msg_body: CryptoTransferCompletedMessage,
|
||||
message: RabbitMessage,
|
||||
logger: ILogger = Depends(get_logger),
|
||||
) -> None:
|
||||
trace_id=msg_body.trace_id
|
||||
token=trace_id_var.set(trace_id)
|
||||
try:
|
||||
payload=msg_body.model_dump(mode='json')
|
||||
logger.info(orjson.dumps({
|
||||
'event':'crypto_transfer_completed_received',
|
||||
'payload':payload,
|
||||
'rabbit_message_id':message.message_id,
|
||||
'rabbit_correlation_id':message.correlation_id,
|
||||
},default=str).decode())
|
||||
finally:
|
||||
trace_id_var.reset(token)
|
||||
|
||||
2
src/presentation/middleware/__init__.py
Normal file
2
src/presentation/middleware/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.presentation.middleware.trace_id import TraceIDMiddleware
|
||||
from src.presentation.middleware.security_headers import SecurityHeadersMiddleware
|
||||
41
src/presentation/middleware/security_headers.py
Normal file
41
src/presentation/middleware/security_headers.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from starlette.types import ASGIApp,Message,Receive,Scope,Send
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
*,
|
||||
hsts: bool = True,
|
||||
hsts_preload: bool = False,
|
||||
frame_options: str = 'DENY',
|
||||
referrer_policy: str = 'strict-origin-when-cross-origin',
|
||||
content_security_policy: str | None = None,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self._headers = {
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-frame-options': frame_options,
|
||||
'referrer-policy': referrer_policy,
|
||||
}
|
||||
if hsts:
|
||||
value = 'max-age=31536000; includeSubDomains'
|
||||
if hsts_preload:
|
||||
value = f'{value}; preload'
|
||||
self._headers['strict-transport-security'] = value
|
||||
if content_security_policy:
|
||||
self._headers['content-security-policy'] = content_security_policy
|
||||
|
||||
|
||||
async def __call__(self,scope: Scope,receive: Receive,send: Send) -> None:
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
headers = list(message.get('headers',[]))
|
||||
for key,value in self._headers.items():
|
||||
headers.append((key.encode(),value.encode()))
|
||||
message['headers'] = headers
|
||||
await send(message)
|
||||
|
||||
await self.app(scope,receive,send_wrapper)
|
||||
135
src/presentation/middleware/trace_id.py
Normal file
135
src/presentation/middleware/trace_id.py
Normal file
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
|
||||
|
||||
# from __future__ import annotations
|
||||
# from typing import Optional
|
||||
# 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.settings import settings
|
||||
#
|
||||
#
|
||||
# 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
|
||||
# self.logger.set_trace_id(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}')
|
||||
# self.logger.clear_trace_id()
|
||||
1
src/presentation/routing/__init__.py
Normal file
1
src/presentation/routing/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.presentation.routing.kyc import kyc_router
|
||||
37
src/presentation/routing/kyc.py
Normal file
37
src/presentation/routing/kyc.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter,Depends
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from src.application.commands import CompleteKycCommand,GetKycSessionCommand,PassKycCommand
|
||||
from src.application.domain.dto import AuthContext
|
||||
from src.presentation.decorators.auth import require_access_token
|
||||
from src.presentation.dependencies.commands import get_complete_kyc_command,get_kyc_session_command,get_pass_kyc_command
|
||||
|
||||
|
||||
kyc_router = APIRouter(prefix='/kyc', tags=['Kyc'])
|
||||
|
||||
|
||||
@kyc_router.post('/create')
|
||||
async def create_kyc(
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: PassKycCommand = Depends(get_pass_kyc_command),
|
||||
) -> ORJSONResponse:
|
||||
user_id = auth.user_id
|
||||
result = await command(user_id=user_id)
|
||||
return ORJSONResponse(result.model_dump())
|
||||
|
||||
|
||||
@kyc_router.get('/session')
|
||||
async def get_kyc_session(
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: GetKycSessionCommand = Depends(get_kyc_session_command),
|
||||
) -> ORJSONResponse:
|
||||
result = await command(user_id=auth.user_id)
|
||||
return ORJSONResponse(result.model_dump())
|
||||
|
||||
|
||||
@kyc_router.post('/complete')
|
||||
async def complete_kyc(
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
command: CompleteKycCommand = Depends(get_complete_kyc_command),
|
||||
) -> ORJSONResponse:
|
||||
result = await command(user_id=auth.user_id)
|
||||
return ORJSONResponse(result.model_dump())
|
||||
0
src/presentation/schemas/__init__.py
Normal file
0
src/presentation/schemas/__init__.py
Normal file
843
uv.lock
generated
Normal file
843
uv.lock
generated
Normal file
@@ -0,0 +1,843 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[[package]]
|
||||
name = "aio-pika"
|
||||
version = "9.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiormq" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/63/56354526f2e6e915c93bee6e4dedb35888fe82d6bc1a19f35f5a77e795ff/aio_pika-9.6.2.tar.gz", hash = "sha256:c49e9246080dc8ffa1bb0e4aca407bf3d8ad78c3ee3a93df88b68fe65d7a49b9", size = 70851, upload-time = "2026-03-22T19:03:20.878Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/05/256fa313f48bed075056d13593b92ce804be05d75f4f312be24edb82860a/aio_pika-9.6.2-py3-none-any.whl", hash = "sha256:2a5478af920d169795071c9c09c7542cd8cdece60438cf7804533dcbcce93b7f", size = 56269, upload-time = "2026-03-22T19:03:19.558Z" },
|
||||
]
|
||||
|
||||
[[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 = "aiormq"
|
||||
version = "6.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pamqp" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/0e/db90154d52d399108903fe603e5110a533c42065180265dd003788264080/aiormq-6.9.4.tar.gz", hash = "sha256:0e7c01b662804e1cc7ace9a17794e8c1192a27fc2afa96162362a6e61ae8e8ef", size = 49232, upload-time = "2026-03-23T09:18:19.493Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/48/1ce3773f392f02ceda37aee168fade9d725483a9592c202d06044cd093ff/aiormq-6.9.4-py3-none-any.whl", hash = "sha256:726a8586695e863fba68cf88842065ab12348c9438dcebdfc9d0bddaf6083277", size = 32166, upload-time = "2026-03-23T09:18:17.523Z" },
|
||||
]
|
||||
|
||||
[[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.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
]
|
||||
|
||||
[[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 = "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 = "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 = "certifi"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[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 = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "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.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fast-depends"
|
||||
version = "3.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/6d/787a21ca8043a8fdb737cf28f645e94a46fc30b44a31de54573299156bad/fast_depends-3.0.8.tar.gz", hash = "sha256:896b16f79a512b6ea1df721b0aa1708a192a06f964be6597e01fcf5412559101", size = 18382, upload-time = "2026-03-02T19:54:28.649Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/1d/e4843e4eeb65f51447b8c22d200d12d8f94f27c97e77bb7162515cc8d61f/fast_depends-3.0.8-py3-none-any.whl", hash = "sha256:4c52c8a3907bca46d43e70e4364d6d016872d9a3aae4bc0c1c85e72e0a6a21c7", size = 25507, upload-time = "2026-03-02T19:54:27.594Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
pydantic = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[[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 = "faststream"
|
||||
version = "0.6.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "fast-depends", extra = ["pydantic"] },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/cc/26deefd97a3d51205554d4fe69ffc2a9144515cda20cb7185be27e11166e/faststream-0.6.6.tar.gz", hash = "sha256:de87502e22db0372131165221728c6993b29d42ba29aaaa0a27d1249803f2ddd", size = 302712, upload-time = "2026-02-03T18:08:35.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/17/169728098799d4f5c4978f9b83d2dc41541eee02ec8547149a085acf11dd/faststream-0.6.6-py3-none-any.whl", hash = "sha256:4aca70628b526d8e27771f1f8edf9cd0a80a62f335a2721ddbbc863e6098f269", size = 507654, upload-time = "2026-02-03T18:08:34.347Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
rabbit = [
|
||||
{ name = "aio-pika" },
|
||||
]
|
||||
|
||||
[[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.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
|
||||
]
|
||||
|
||||
[[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.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kyc"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "apscheduler" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "dotenv" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "faststream", extra = ["rabbit"] },
|
||||
{ name = "granian" },
|
||||
{ name = "hvac" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose" },
|
||||
{ name = "python-ulid" },
|
||||
{ name = "redis" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "uvloop", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = "==3.13.5" },
|
||||
{ name = "apscheduler", specifier = "==3.11.2" },
|
||||
{ name = "asyncpg", specifier = "==0.31.0" },
|
||||
{ name = "bcrypt", specifier = "==5.0.0" },
|
||||
{ name = "dotenv", specifier = "==0.9.9" },
|
||||
{ name = "email-validator", specifier = "==2.3.0" },
|
||||
{ name = "fastapi", specifier = "==0.128.7" },
|
||||
{ name = "faststream", extras = ["rabbit"], specifier = "==0.6.6" },
|
||||
{ name = "granian", specifier = "==2.6.1" },
|
||||
{ name = "hvac", specifier = "==2.4.0" },
|
||||
{ name = "itsdangerous", specifier = "==2.2.0" },
|
||||
{ name = "orjson", specifier = "==3.11.7" },
|
||||
{ name = "pydantic-settings", specifier = "==2.12.0" },
|
||||
{ name = "python-jose", specifier = "==3.5.0" },
|
||||
{ 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 = "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 = "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 = "pamqp"
|
||||
version = "3.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" },
|
||||
]
|
||||
|
||||
[[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 = "pyasn1"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" },
|
||||
]
|
||||
|
||||
[[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 = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[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-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 = "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 = "requests"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[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 = "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 = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
|
||||
]
|
||||
|
||||
[[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 = "yarl"
|
||||
version = "1.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "multidict" },
|
||||
{ name = "propcache" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user