diff --git a/.dockerignore b/.dockerignore
index b394fed..073be06 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -18,6 +18,7 @@ celerybeat-schedule
.cache/
start_dev.ps1
scripts
+test.db
# coverage
.coverage
diff --git a/.env.example b/.env.example
index 369dc5c..5457ad0 100644
--- a/.env.example
+++ b/.env.example
@@ -4,24 +4,20 @@ DATABASE__USER=postgres
DATABASE__PASSWORD=your_secure_password
DATABASE__DB=deribit_tracker
-DERIBIT_API__CLIENT_ID=your_client_id
-DERIBIT_API__CLIENT_SECRET=your_client_secret
DERIBIT_API__BASE_URL=https://www.deribit.com/api/v2
REDIS__HOST=redis
REDIS__PORT=6379
REDIS__DB=0
-REDIS__PASSWORD=your_secure_password_or_empty_for_local_dev
+REDIS__PASSWORD=
REDIS__SSL=False
-CELERY__WORKER_CONCURRENCY=2
+CELERY__WORKER_CONCURRENCY=1
CELERY__BEAT_ENABLED=True
CELERY__TASK_TRACK_STARTED=True
APPLICATION__DEBUG=False
APPLICATION__API_V1_PREFIX=/api/v1
-APPLICATION__PROJECT_NAME=Deribit Price Tracker API
-APPLICATION__VERSION=0.3.0
CORS__ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 11aa3b6..e0ef91c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,29 +32,26 @@ jobs:
cat > .env << 'EOF'
DATABASE__HOST=localhost
DATABASE__PORT=5433
- DATABASE__USER=test_user
- DATABASE__PASSWORD=test_password
- DATABASE__DB=test_db
+ DATABASE__USER=postgres
+ DATABASE__PASSWORD=your_secure_password
+ DATABASE__DB=deribit_tracker
- DERIBIT_API__CLIENT_ID=test_client_id
- DERIBIT_API__CLIENT_SECRET=test_client_secret
+ DERIBIT_API__BASE_URL=https://www.deribit.com/api/v2
- REDIS__HOST=localhost
+ REDIS__HOST=redis
REDIS__PORT=6379
REDIS__DB=0
- REDIS__PASSWORD=your_secure_password_or_empty_for_local_dev
+ REDIS__PASSWORD=
REDIS__SSL=False
CELERY__WORKER_CONCURRENCY=2
CELERY__BEAT_ENABLED=True
CELERY__TASK_TRACK_STARTED=True
- APPLICATION__DEBUG=false
+ APPLICATION__DEBUG=False
APPLICATION__API_V1_PREFIX=/api/v1
- APPLICATION__PROJECT_NAME=Deribit Price Tracker Test
- APPLICATION__VERSION=1.0.0
- CORS__ORIGINS=["http://localhost:8000"]
+ CORS__ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
APP_PORT=8000
EOF
diff --git a/.gitignore b/.gitignore
index 3175acc..5ca1943 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ coverage.xml
pytest.xml
test-results/
*.log
+test.db
# Python
__pycache__/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7bcb3f0..e0f59b4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -23,29 +23,26 @@ before_script:
@'
DATABASE__HOST=localhost
DATABASE__PORT=5433
- DATABASE__USER=test_user
- DATABASE__PASSWORD=test_password
- DATABASE__DB=test_db
+ DATABASE__USER=postgres
+ DATABASE__PASSWORD=your_secure_password
+ DATABASE__DB=deribit_tracker
- DERIBIT_API__CLIENT_ID=test_client_id
- DERIBIT_API__CLIENT_SECRET=test_client_secret
+ DERIBIT_API__BASE_URL=https://www.deribit.com/api/v2
- REDIS__HOST=localhost
+ REDIS__HOST=redis
REDIS__PORT=6379
REDIS__DB=0
- REDIS__PASSWORD=your_secure_password_or_empty_for_local_dev
+ REDIS__PASSWORD=
REDIS__SSL=False
CELERY__WORKER_CONCURRENCY=2
CELERY__BEAT_ENABLED=True
CELERY__TASK_TRACK_STARTED=True
- APPLICATION__DEBUG=false
+ APPLICATION__DEBUG=False
APPLICATION__API_V1_PREFIX=/api/v1
- APPLICATION__PROJECT_NAME=Deribit Price Tracker Test
- APPLICATION__VERSION=1.0.0
- CORS__ORIGINS=["http://localhost:8000"]
+ CORS__ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
APP_PORT=8000
'@ | Out-File -FilePath .env -Encoding UTF8
diff --git a/.secrets.baseline b/.secrets.baseline
index 2df9f64..b90cd5d 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -127,7 +127,7 @@
{
"type": "Secret Keyword",
"filename": ".github\\workflows\\ci.yml",
- "hashed_secret": "df842c49d8d3277a0170ffac5782a3cbe61b1feb",
+ "hashed_secret": "702f38878ff96df04408e2d68d2afc48c2c59717",
"is_verified": false,
"line_number": 31
},
@@ -136,7 +136,7 @@
"filename": ".github\\workflows\\ci.yml",
"hashed_secret": "dc5f72fcc64e44ece1aa8dfab21ddfce0fc8772b",
"is_verified": false,
- "line_number": 106
+ "line_number": 103
}
],
"alembic.ini": [
@@ -163,67 +163,9 @@
"filename": "app\\core\\config.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
- "line_number": 43
- }
- ],
- "tests\\test_config.py": [
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
- "is_verified": false,
- "line_number": 32
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e",
- "is_verified": false,
- "line_number": 73
- },
- {
- "type": "Basic Auth Credentials",
- "filename": "tests\\test_config.py",
- "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e",
- "is_verified": false,
- "line_number": 78
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "1adfce9fa4bc6b1cbdf95ac2dc6180175da7558b",
- "is_verified": false,
- "line_number": 89
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0",
- "is_verified": false,
- "line_number": 121
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "ee27c133da056b1013f88c712f92460bc7b3c90a",
- "is_verified": false,
- "line_number": 129
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
- "is_verified": false,
- "line_number": 240
- },
- {
- "type": "Secret Keyword",
- "filename": "tests\\test_config.py",
- "hashed_secret": "fca268ae2442d5cabc3e12d87b349adf8bf7d76c",
- "is_verified": false,
- "line_number": 316
+ "line_number": 47
}
]
},
- "generated_at": "2026-01-26T20:19:50Z"
+ "generated_at": "2026-01-29T14:55:03Z"
}
diff --git a/Dockerfile b/Dockerfile
index 95721d2..80dcf00 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,5 +18,3 @@ COPY . .
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
-
-CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/README.md b/README.md
index e69de29..13c01c7 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,365 @@
+
+
+# Deribit Price Tracker
+
+[English](#english) | [Русский](#russian)
+
+
+
+
+
+[](https://github.com/script-logic/deribit-tracker/actions)
+
+
+
+
+
+## Overview
+
+Deribit Price Tracker is a high-performance cryptocurrency price monitoring system that collects and stores BTC/USD and ETH/USD index prices from the Deribit exchange. It provides a REST API for data access and visualization through an interactive dashboard.
+
+## Features
+
+- Real-time price collection from Deribit API
+- Historical price data storage with PostgreSQL
+- RESTful API with FastAPI
+- Scheduled tasks with Celery + Redis
+- Interactive web dashboard
+- Docker containerization
+- Comprehensive test coverage
+- API documentation with Swagger/ReDoc
+
+## Quick Start
+
+1. Clone the repository:
+```bash
+git clone https://github.com/script-logic/deribit-tracker.git
+cd deribit-tracker
+```
+
+2. Create `.env` file from example:
+```bash
+cp .env.example .env
+```
+
+3. Start with Docker Compose:
+```bash
+docker-compose up -d
+```
+
+4. Access services:
+- Web Dashboard: http://localhost:8000
+- API Documentation: http://localhost:8000/docs
+- ReDoc: http://localhost:8000/redoc
+
+## API Endpoints
+
+### Price Data
+- `GET /api/v1/prices/` - Get all prices for ticker
+- `GET /api/v1/prices/latest` - Get latest price
+- `GET /api/v1/prices/at-timestamp` - Get price at specific timestamp
+- `GET /api/v1/prices/by-date` - Get prices within date range
+- `GET /api/v1/prices/stats` - Get price statistics
+
+## Development
+
+### Prerequisites
+- Python 3.11+
+- Poetry
+- PostgreSQL 15+
+- Redis 7+
+
+### Setup Development Environment
+```bash
+# Install dependencies
+poetry install --with dev
+
+# Apply migrations
+poetry run alembic upgrade head
+
+# Start development server
+poetry run uvicorn app.main:app --reload
+
+# Run tests
+poetry run pytest
+```
+
+## Architecture
+```mermaid
+flowchart LR
+ %% --- Стилизация ---
+ classDef db fill:#ffcc80,stroke:#ef6c00,color:black,stroke-width:2px;
+
+ %% --- Узлы ---
+ subgraph External
+ direction TB
+ DB[(PostgreSQL)]:::db
+ Deribit[Deribit API]:::ext
+ end
+
+ subgraph Infrastructure
+ Redis[(Redis)]:::db
+ end
+
+ subgraph Application
+ API[FastAPI App]:::app
+
+ subgraph Logic
+ direction TB
+ PS[Price Service]:::app
+ Rep[Repository]:::app
+ Client[Deribit Client]:::app
+ end
+
+ subgraph Workers
+ direction TB
+ Beat[Celery Beat]:::task
+ Celery[Celery Worker]:::task
+ PC[Tasks: Price Collection]:::task
+ end
+
+ HC(Health Check):::task
+ end
+
+ API --> PS
+ PS --> Rep
+ Rep ==> DB
+
+ Beat --> Redis
+ Redis --> Celery
+ Celery --> PC
+ PC --> Client
+ PC --> Rep
+
+ Client -- HTTP/WS --> Deribit
+
+ HC -.-> |ping| Client
+ HC -.-> |ping| DB
+ HC -.-> |ping| Redis
+```
+
+## Design Decisions
+
+### Architecture
+- **Clean Architecture** principles with clear separation of concerns:
+ - Core business logic in services layer
+ - Repository pattern for data access
+ - Dependency injection for loose coupling
+ - API layer with FastAPI for HTTP interface
+
+### Data Collection
+- **Celery Tasks** for reliable scheduled price collection:
+ - Configurable retry mechanism
+ - Error handling and logging
+ - Redis as message broker and result backend
+ - Task queues for scalability
+
+### Database
+- **PostgreSQL** chosen for:
+ - ACID compliance
+ - Index support for efficient queries
+ - JSON support for future extensibility
+ - Async driver support (asyncpg)
+
+### API Design
+- **RESTful principles** with:
+ - Query parameters for filtering
+ - Consistent error responses
+ - Comprehensive validation
+ - Swagger/OpenAPI documentation
+
+### Testing
+- **Comprehensive test suite**:
+ - Unit tests with pytest
+ - Async test support
+ - Mock frameworks for external services
+ - CI/CD pipeline with GitHub Actions
+
+### Monitoring
+- **Logging and metrics**:
+ - Structured logging
+ - Health check endpoints
+ - Error tracking
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+
+
+
+
+# Отслеживание цен Deribit
+
+## Обзор
+
+Deribit Price Tracker - это высокопроизводительная система мониторинга криптовалютных цен, которая собирает и хранит индексные цены BTC/USD и ETH/USD с биржи Deribit. Она предоставляет REST API для доступа к данным и визуализации через интерактивную панель управления.
+
+## Возможности
+
+- Сбор цен в реальном времени через API Deribit
+- Хранение исторических данных в PostgreSQL
+- RESTful API на FastAPI
+- Планировщик задач на Celery + Redis
+- Интерактивная веб-панель
+- Контейнеризация Docker
+- Полное тестовое покрытие
+- Документация API в Swagger/ReDoc
+
+## Быстрый старт
+
+1. Клонируйте репозиторий:
+```bash
+git clone https://github.com/script-logic/deribit-tracker.git
+cd deribit-tracker
+```
+
+2. Создайте файл `.env` из примера:
+```bash
+cp .env.example .env
+```
+
+3. Запустите через Docker Compose:
+```bash
+docker-compose up -d
+```
+
+4. Доступ к сервисам:
+- Веб-панель: http://localhost:8000
+- Документация API: http://localhost:8000/docs
+- ReDoc: http://localhost:8000/redoc
+
+## Конечные точки API
+
+### Данные о ценах
+- `GET /api/v1/prices/` - Получить все цены для тикера
+- `GET /api/v1/prices/latest` - Получить последнюю цену
+- `GET /api/v1/prices/at-timestamp` - Получить цену на конкретный момент времени
+- `GET /api/v1/prices/by-date` - Получить цены в диапазоне дат
+- `GET /api/v1/prices/stats` - Получить статистику цен
+
+## Разработка
+
+### Требования
+- Python 3.11+
+- Poetry
+- PostgreSQL 15+
+- Redis 7+
+
+### Настройка окружения разработки
+```bash
+# Установка зависимостей
+poetry install --with dev
+
+# Применение миграций
+poetry run alembic upgrade head
+
+# Запуск сервера разработки
+poetry run uvicorn app.main:app --reload
+
+# Запуск тестов
+poetry run pytest
+```
+
+## Архитектура
+```mermaid
+flowchart LR
+ %% --- Стилизация ---
+ classDef db fill:#ffcc80,stroke:#ef6c00,color:black,stroke-width:2px;
+
+ %% --- Узлы ---
+ subgraph External
+ direction TB
+ DB[(PostgreSQL)]:::db
+ Deribit[Deribit API]:::ext
+ end
+
+ subgraph Infrastructure
+ Redis[(Redis)]:::db
+ end
+
+ subgraph Application
+ API[FastAPI App]:::app
+
+ subgraph Logic
+ direction TB
+ PS[Price Service]:::app
+ Rep[Repository]:::app
+ Client[Deribit Client]:::app
+ end
+
+ subgraph Workers
+ direction TB
+ Beat[Celery Beat]:::task
+ Celery[Celery Worker]:::task
+ PC[Tasks: Price Collection]:::task
+ end
+
+ HC(Health Check):::task
+ end
+
+ API --> PS
+ PS --> Rep
+ Rep ==> DB
+
+ Beat --> Redis
+ Redis --> Celery
+ Celery --> PC
+ PC --> Client
+ PC --> Rep
+
+ Client -- HTTP/WS --> Deribit
+
+ HC -.-> |ping| Client
+ HC -.-> |ping| DB
+ HC -.-> |ping| Redis
+```
+
+## Архитектурные решения
+
+### Архитектура
+- **Принципы Clean Architecture** с четким разделением ответственности:
+ - Бизнес-логика в сервисном слое
+ - Паттерн Repository для доступа к данным
+ - Внедрение зависимостей для слабого связывания
+ - API слой с FastAPI для HTTP интерфейса
+
+### Сбор данных
+- **Задачи Celery** для надежного планового сбора цен:
+ - Настраиваемый механизм повторных попыток
+ - Обработка ошибок и логирование
+ - Redis как брокер сообщений и хранилище результатов
+ - Очереди задач для масштабируемости
+
+### База данных
+- **PostgreSQL** выбран для:
+ - ACID-совместимость
+ - Поддержка индексов для эффективных запросов
+ - Поддержка JSON для будущего расширения
+ - Поддержка асинхронного драйвера (asyncpg)
+
+### Дизайн API
+- **Принципы REST** с:
+ - Query-параметры для фильтрации
+ - Согласованные ответы об ошибках
+ - Комплексная валидация
+ - Документация Swagger/OpenAPI
+
+### Тестирование
+- **Комплексный набор тестов**:
+ - Модульные тесты с pytest
+ - Поддержка асинхронного тестирования
+ - Фреймворки для мокирования внешних сервисов
+ - CI/CD pipeline с GitHub Actions
+
+### Мониторинг
+- **Логирование и метрики**:
+ - Структурированное логирование
+ - Health check
+ - Отслеживание ошибок
+
+## Лицензия
+
+Этот проект лицензирован под MIT License - см. файл [LICENSE](LICENSE) для подробностей.
+
+
diff --git a/alembic/env.py b/alembic/env.py
index 6fe8858..a480e15 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -12,11 +12,12 @@
sys.path.append(str(Path(__file__).parent.parent))
-from app.core import settings
+from app.core import get_settings
from app.database import Base
-from app.database.models import PriceTick # noqa
+from app.database.models import PriceTick # type: ignore # noqa: F401
config = context.config
+settings = get_settings()
if config.config_file_name is not None:
fileConfig(config.config_file_name)
diff --git a/app/__init__.py b/app/__init__.py
index 544007b..d4496ab 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,52 +1,3 @@
"""
Deribit Price Tracker API application package.
-
-Main application module with metadata and imports.
"""
-
-from functools import lru_cache
-from importlib.metadata import metadata
-
-from . import api, clients, core, database, services, tasks
-
-
-@lru_cache
-def get_app_metadata() -> tuple[str, str, str]:
- """
- Retrieves application metadata from the installed package information.
-
- Returns:
- tuple: A three-element tuple containing:
- version (str): Current application version (e.g., "0.3.0")
- description (str): Brief application description
- title (str): Formatted application title (e.g., "Deribit Tracker")
-
- Note: Requires package to be installed (e.g., via poetry install)
- """
- try:
- app_meta = metadata("deribit-tracker").json
- version = str(app_meta.get("version", "Unknown version"))
- description = str(app_meta.get("summary", "Unknown description"))
- title = str(app_meta.get("name", "Untitled")).replace("-", " ").title()
- except Exception:
- version = "Unknown version"
- description = "Unknown description"
- title = "Untitled"
-
- return version, description, title
-
-
-version, description, title = get_app_metadata()
-
-
-__all__ = [
- "api",
- "clients",
- "core",
- "database",
- "description",
- "services",
- "tasks",
- "title",
- "version",
-]
diff --git a/app/api/__init__.py b/app/api/__init__.py
index e69de29..dc7a315 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -0,0 +1,13 @@
+"""
+API module package exports.
+"""
+
+from .exceptions import register_exception_handlers
+from .routes import api_v1_router, health_check_router, root_router
+
+__all__ = [
+ "api_v1_router",
+ "health_check_router",
+ "register_exception_handlers",
+ "root_router",
+]
diff --git a/app/api/exceptions.py b/app/api/exceptions.py
new file mode 100644
index 0000000..de7af5f
--- /dev/null
+++ b/app/api/exceptions.py
@@ -0,0 +1,164 @@
+"""
+Custom exceptions and error handlers for API error handling.
+"""
+
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, status
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+
+from app.core import get_logger
+
+logger = get_logger(__name__)
+
+
+class APIError(HTTPException):
+ """Base exception for API errors."""
+
+ def __init__(
+ self,
+ message: str,
+ status_code: int = 500,
+ error_type: str = "api_error",
+ details: dict[str, Any] | None = None,
+ ):
+ super().__init__(status_code=status_code, detail=message)
+ self.error_type = error_type
+ self.details = details or {}
+
+
+class ValidationError(APIError):
+ """Raised when request validation fails."""
+
+ def __init__(self, message: str, details: dict[str, Any] | None = None):
+ super().__init__(
+ message=message,
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ error_type="validation_error",
+ details=details,
+ )
+
+
+class NotFoundError(APIError):
+ """Raised when requested resource is not found."""
+
+ def __init__(self, resource: str, identifier: str | None = None):
+ if identifier:
+ message = f"{resource} '{identifier}' not found"
+ else:
+ message = f"{resource} not found"
+
+ super().__init__(
+ message=message,
+ status_code=status.HTTP_404_NOT_FOUND,
+ error_type="not_found",
+ details={"resource": resource, "identifier": identifier},
+ )
+
+
+class ServiceUnavailableError(APIError):
+ """Raised when external service is unavailable."""
+
+ def __init__(self, service: str, details: dict[str, Any] | None = None):
+ super().__init__(
+ message=f"{service} service is temporarily unavailable",
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ error_type="service_unavailable",
+ details={"service": service, **(details or {})},
+ )
+
+
+async def http_exception_handler( # noqa: RUF029
+ request: Request,
+ exc: Exception,
+) -> JSONResponse:
+ """Handle HTTPException and APIError."""
+ if not isinstance(exc, HTTPException):
+ raise exc
+
+ response_content: dict[str, Any]
+
+ if isinstance(exc, APIError):
+ logger.warning(
+ "API error: %s (status: %s, type: %s)",
+ exc.detail,
+ exc.status_code,
+ exc.error_type,
+ extra={"details": exc.details},
+ )
+
+ response_content = {
+ "detail": exc.detail,
+ "error_type": exc.error_type,
+ "details": exc.details,
+ }
+ else:
+ logger.warning(
+ "HTTP exception: %s (status: %s)",
+ exc.detail,
+ exc.status_code,
+ )
+
+ response_content = {
+ "detail": exc.detail,
+ "error_type": "http_error",
+ }
+
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=response_content,
+ )
+
+
+async def validation_exception_handler( # noqa: RUF029
+ request: Request,
+ exc: Exception,
+) -> JSONResponse:
+ """Handle validation errors."""
+ if not isinstance(exc, RequestValidationError):
+ raise exc
+
+ logger.warning(
+ "Validation error: %s",
+ exc.errors(),
+ )
+
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content={
+ "detail": "Validation failed",
+ "error_type": "validation_error",
+ "errors": exc.errors(),
+ },
+ )
+
+
+async def generic_exception_handler( # noqa: RUF029
+ request: Request,
+ exc: Exception,
+) -> JSONResponse:
+ """Handle all other exceptions."""
+ logger.error(
+ "Unhandled exception: %s",
+ exc,
+ exc_info=True,
+ )
+
+ return JSONResponse(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ content={
+ "detail": "Internal server error",
+ "error_type": "internal_error",
+ },
+ )
+
+
+def register_exception_handlers(app: FastAPI) -> None:
+ """Register all exception handlers for FastAPI app."""
+ app.add_exception_handler(HTTPException, http_exception_handler)
+ app.add_exception_handler(
+ RequestValidationError,
+ validation_exception_handler,
+ )
+ app.add_exception_handler(Exception, generic_exception_handler)
diff --git a/app/api/routes.py b/app/api/routes.py
new file mode 100644
index 0000000..f928ef3
--- /dev/null
+++ b/app/api/routes.py
@@ -0,0 +1,23 @@
+"""
+API version 1 router configuration.
+"""
+
+from fastapi import APIRouter
+
+from .v1.endpoints import prices_router
+
+api_v1_router = APIRouter()
+root_router = APIRouter()
+health_check_router = APIRouter()
+
+
+@health_check_router.get("/health")
+async def health_check():
+ return {"status": "healthy"}
+
+
+api_v1_router.include_router(
+ prices_router,
+ prefix="/prices",
+ tags=["prices"],
+)
diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py
index e69de29..be036de 100644
--- a/app/api/v1/endpoints/__init__.py
+++ b/app/api/v1/endpoints/__init__.py
@@ -0,0 +1,5 @@
+from .prices import router as prices_router
+
+__all__ = [
+ "prices_router",
+]
diff --git a/app/api/v1/endpoints/prices.py b/app/api/v1/endpoints/prices.py
index e69de29..b48012f 100644
--- a/app/api/v1/endpoints/prices.py
+++ b/app/api/v1/endpoints/prices.py
@@ -0,0 +1,428 @@
+"""
+FastAPI endpoints for cryptocurrency price data.
+"""
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+
+from app.api.exceptions import NotFoundError
+from app.api.v1.schemas import (
+ DateFilterParams,
+ ErrorResponse,
+ PaginationParams,
+ PriceStatsResponse,
+ PriceTickResponse,
+)
+from app.core import get_logger
+from app.dependencies import PriceServiceDep
+
+logger = get_logger(__name__)
+
+router = APIRouter()
+
+
+@router.get(
+ "/",
+ response_model=list[PriceTickResponse],
+ summary="Get all prices for ticker",
+ description=(
+ "Retrieve all price records for specified cryptocurrency ticker "
+ "with pagination support."
+ ),
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid ticker"},
+ 404: {"model": ErrorResponse, "description": "No prices found"},
+ },
+)
+async def get_all_prices(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ pagination: Annotated[PaginationParams, Depends()],
+ price_service: PriceServiceDep,
+) -> list[PriceTickResponse]:
+ """
+ Get all price records for specified ticker.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ pagination: Pagination parameters (limit, offset).
+ price_service: PriceService instance.
+
+ Returns:
+ List of price records.
+ """
+ logger.info("Getting all prices for %s", ticker)
+
+ try:
+ price_ticks = await price_service.get_all_prices(
+ ticker=ticker,
+ limit=pagination.limit,
+ offset=pagination.offset,
+ )
+
+ if not price_ticks:
+ raise NotFoundError(resource="prices", identifier=ticker)
+
+ return [PriceTickResponse.model_validate(tick) for tick in price_ticks]
+
+ except ValueError as error:
+ logger.warning("Validation error for ticker %s: %s", ticker, error)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
+
+
+@router.get(
+ "/latest",
+ response_model=PriceTickResponse,
+ summary="Get latest price",
+ description="Retrieve the most recent price for specified ticker.",
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid ticker"},
+ 404: {"model": ErrorResponse, "description": "No prices found"},
+ },
+)
+async def get_latest_price(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ price_service: PriceServiceDep,
+) -> PriceTickResponse:
+ """
+ Get latest price for ticker.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ price_service: PriceService instance.
+
+ Returns:
+ Latest price record.
+ """
+ logger.info("Getting latest price for %s", ticker)
+
+ try:
+ price_tick = await price_service.get_latest_price(ticker)
+
+ if price_tick is None:
+ raise NotFoundError(resource="latest_price", identifier=ticker)
+
+ result: PriceTickResponse = PriceTickResponse.model_validate(
+ price_tick,
+ )
+ return result
+
+ except ValueError as error:
+ logger.warning("Validation error for ticker %s: %s", ticker, error)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
+
+
+@router.get(
+ "/at-timestamp",
+ response_model=PriceTickResponse,
+ summary="Get price at timestamp",
+ description="Retrieve price at exact UNIX timestamp.",
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid parameters"},
+ 404: {"model": ErrorResponse, "description": "Price not found"},
+ },
+)
+async def get_price_at_timestamp(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ timestamp: Annotated[
+ int,
+ Query(
+ ...,
+ description="UNIX timestamp",
+ examples=[1700000000],
+ ge=0,
+ ),
+ ],
+ price_service: PriceServiceDep,
+) -> PriceTickResponse:
+ """
+ Get price at exact timestamp.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ timestamp: UNIX timestamp (required).
+ price_service: PriceService instance.
+
+ Returns:
+ Price record at specified timestamp.
+ """
+ logger.info(
+ "Getting price for %s at timestamp %s",
+ ticker,
+ timestamp,
+ )
+
+ try:
+ price_tick = await price_service.get_price_at_timestamp(
+ ticker=ticker,
+ timestamp=timestamp,
+ )
+
+ if price_tick is None:
+ raise NotFoundError(resource="price_at_timestamp", identifier="")
+
+ result: PriceTickResponse = PriceTickResponse.model_validate(
+ price_tick,
+ )
+ return result
+
+ except ValueError as error:
+ logger.warning(
+ "Validation error for %s at %s: %s",
+ ticker,
+ timestamp,
+ error,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
+
+
+@router.get(
+ "/closest-to-timestamp",
+ response_model=PriceTickResponse,
+ summary="Get price closest to timestamp",
+ description=(
+ "Retrieve price closest to specified UNIX timestamp "
+ "within 60 seconds window."
+ ),
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid parameters"},
+ 404: {"model": ErrorResponse, "description": "Price not found"},
+ },
+)
+async def get_price_closest_to_timestamp(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ timestamp: Annotated[
+ int,
+ Query(
+ ...,
+ description="UNIX timestamp",
+ examples=[1700000000],
+ ge=0,
+ ),
+ ],
+ price_service: PriceServiceDep,
+ max_difference: Annotated[
+ int,
+ Query(
+ description="Maximum time difference in seconds",
+ ge=1,
+ le=3600,
+ alias="maxDifference",
+ ),
+ ] = 60,
+) -> PriceTickResponse:
+ """
+ Get price closest to timestamp.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ timestamp: Target UNIX timestamp (required).
+ max_difference: Maximum time difference in seconds.
+ price_service: PriceService instance.
+
+ Returns:
+ Closest price record.
+ """
+ logger.info(
+ "Getting price closest to %s for %s (±%s seconds)",
+ timestamp,
+ ticker,
+ max_difference,
+ )
+
+ try:
+ price_tick = await price_service.get_price_closest_to_timestamp(
+ ticker=ticker,
+ timestamp=timestamp,
+ max_difference_seconds=max_difference,
+ )
+
+ if price_tick is None:
+ raise NotFoundError(
+ resource="price_closest_to_timestamp",
+ identifier="",
+ )
+
+ result: PriceTickResponse = PriceTickResponse.model_validate(
+ price_tick,
+ )
+ return result
+
+ except ValueError as error:
+ logger.warning(
+ "Validation error for %s closest to %s: %s",
+ ticker,
+ timestamp,
+ error,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
+
+
+@router.get(
+ "/by-date",
+ response_model=list[PriceTickResponse],
+ summary="Get prices by date range",
+ description="Retrieve prices within specified date range.",
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid parameters"},
+ 404: {"model": ErrorResponse, "description": "No prices found"},
+ },
+)
+async def get_prices_by_date(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ date_filter: Annotated[DateFilterParams, Depends()],
+ price_service: PriceServiceDep,
+) -> list[PriceTickResponse]:
+ """
+ Get prices by date range.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ date_filter: Date range filter parameters.
+ price_service: PriceService instance.
+
+ Returns:
+ List of price records within date range.
+ """
+ logger.info(
+ "Getting prices for %s from %s to %s",
+ ticker,
+ date_filter.start_date,
+ date_filter.end_date,
+ )
+
+ try:
+ price_ticks = await price_service.get_prices_by_date_range(
+ ticker=ticker,
+ start_date=date_filter.start_date,
+ end_date=date_filter.end_date,
+ )
+
+ if not price_ticks:
+ raise NotFoundError(resource="prices_by_date", identifier=ticker)
+
+ return [PriceTickResponse.model_validate(tick) for tick in price_ticks]
+
+ except ValueError as error:
+ logger.warning(
+ "Validation error for %s date filter: %s",
+ ticker,
+ error,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
+
+
+@router.get(
+ "/stats",
+ response_model=PriceStatsResponse,
+ summary="Get price statistics",
+ description="Retrieve comprehensive statistics for ticker.",
+ responses={
+ 400: {"model": ErrorResponse, "description": "Invalid parameters"},
+ 404: {"model": ErrorResponse, "description": "No prices found"},
+ },
+)
+async def get_price_statistics(
+ ticker: Annotated[
+ str,
+ Query(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ ),
+ ],
+ price_service: PriceServiceDep,
+ timestamp: Annotated[
+ int | None,
+ Query(
+ description="Optional target timestamp for specific price",
+ examples=[1700000000],
+ ge=0,
+ ),
+ ] = None,
+) -> PriceStatsResponse:
+ """
+ Get price statistics.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol (required).
+ timestamp: Optional target timestamp.
+ price_service: PriceService instance.
+
+ Returns:
+ Comprehensive price statistics.
+ """
+ logger.info("Getting statistics for %s", ticker)
+
+ try:
+ stats = await price_service.get_price_statistics(
+ ticker=ticker,
+ target_timestamp=timestamp,
+ )
+
+ if stats["count"] == 0:
+ raise NotFoundError(resource="price_statistics", identifier=ticker)
+
+ result: PriceStatsResponse = PriceStatsResponse.model_validate(stats)
+ return result
+
+ except ValueError as error:
+ logger.warning(
+ "Validation error for %s statistics: %s",
+ ticker,
+ error,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(error),
+ ) from error
diff --git a/app/api/v1/schemas.py b/app/api/v1/schemas.py
index e69de29..d99f3bc 100644
--- a/app/api/v1/schemas.py
+++ b/app/api/v1/schemas.py
@@ -0,0 +1,144 @@
+"""
+Pydantic schemas for API request/response validation.
+"""
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+class PriceTickBase(BaseModel):
+ """Base schema for price tick data."""
+
+ ticker: str = Field(
+ ...,
+ description="Cryptocurrency ticker symbol",
+ examples=["btc_usd", "eth_usd"],
+ )
+ price: float = Field(
+ ...,
+ description="Current index price in USD",
+ examples=[45000.50, 2500.75],
+ ge=0,
+ )
+ timestamp: int = Field(
+ ...,
+ description="UNIX timestamp when price was recorded",
+ examples=[1700000000],
+ ge=0,
+ )
+
+
+class PriceTickCreate(PriceTickBase):
+ """Schema for creating new price tick."""
+
+ @field_validator("ticker")
+ @classmethod
+ def validate_ticker(cls, v: str) -> str:
+ """Validate ticker format."""
+ v = v.strip().lower()
+ if v not in {"btc_usd", "eth_usd"}:
+ raise ValueError(
+ f"Unsupported ticker: {v}. Supported: 'btc_usd', 'eth_usd'"
+ )
+ return v
+
+
+class PriceTickResponse(PriceTickBase):
+ """Schema for price tick response."""
+
+ id: int = Field(..., description="Unique identifier")
+ created_at: datetime = Field(..., description="Record creation timestamp")
+
+ model_config = ConfigDict(
+ from_attributes=True,
+ json_schema_extra={
+ "example": {
+ "id": 1,
+ "ticker": "btc_usd",
+ "price": 45000.50,
+ "timestamp": 1700000000,
+ "created_at": "2026-01-01T00:00:00Z",
+ }
+ },
+ )
+
+
+class PriceCollectionResponse(BaseModel):
+ """Schema for price collection task response."""
+
+ successful: int = Field(
+ ...,
+ description="Number of successful collections",
+ )
+ failed: int = Field(..., description="Number of failed collections")
+ timestamp: int = Field(..., description="Collection timestamp")
+ results: list[dict[str, Any]] | None = Field(
+ default=None,
+ description="Detailed collection results",
+ )
+
+
+class ErrorResponse(BaseModel):
+ """Schema for error responses."""
+
+ detail: str = Field(..., description="Error description")
+ error_type: str | None = Field(default=None, description="Error type")
+
+
+class PaginationParams(BaseModel):
+ """Schema for pagination parameters."""
+
+ limit: int = Field(
+ default=100,
+ description="Maximum number of records to return",
+ ge=1,
+ le=1000,
+ )
+ offset: int = Field(
+ default=0,
+ description="Number of records to skip",
+ ge=0,
+ )
+
+
+class DateFilterParams(BaseModel):
+ """Schema for date filtering parameters."""
+
+ start_date: datetime | None = Field(
+ default=None,
+ description="Start date for filtering (inclusive)",
+ examples=["2026-01-01T00:00:00Z"],
+ )
+ end_date: datetime | None = Field(
+ default=None,
+ description="End date for filtering (inclusive)",
+ examples=["2026-01-02T00:00:00Z"],
+ )
+
+
+class PriceStatsResponse(BaseModel):
+ """Schema for price statistics."""
+
+ ticker: str = Field(..., description="Cryptocurrency ticker symbol")
+ latest_price: float | None = Field(
+ default=None,
+ description="Latest price recorded",
+ )
+ latest_timestamp: int | None = Field(
+ default=None,
+ description="Timestamp of latest price",
+ )
+ count: int = Field(..., description="Total number of records")
+ price_at_time: float | None = Field(
+ default=None,
+ description="Price at requested timestamp",
+ )
+ closest_price: float | None = Field(
+ default=None,
+ description="Price closest to requested timestamp",
+ )
+ min_price: float | None = Field(default=None, description="Minimum price")
+ max_price: float | None = Field(default=None, description="Maximum price")
+ avg_price: float | None = Field(default=None, description="Average price")
diff --git a/app/clients/deribit.py b/app/clients/deribit.py
index 17d8d1c..cda7caf 100644
--- a/app/clients/deribit.py
+++ b/app/clients/deribit.py
@@ -11,23 +11,15 @@
import aiohttp
from aiohttp import ClientError, ClientResponseError, ClientTimeout
-from app.core import get_logger, settings
+from app.core import get_logger
-logger = get_logger(__name__)
-
-
-class DeribitAPIError(Exception):
- """Base exception for Deribit API errors."""
-
- def __init__(self, message: str, status_code: int | None = None):
- self.message = message
- self.status_code = status_code
- super().__init__(message)
+from .exceptions import DeribitAPIError
class DeribitClient:
"""
- Async client for Deribit API with connection pooling and error handling.
+ Async client for Deribit API with connection pooling and error
+ handling.
Uses aiohttp for efficient async HTTP requests with configurable
timeouts and retry logic for public endpoints.
@@ -36,14 +28,19 @@ class DeribitClient:
_session: aiohttp.ClientSession | None = None
_timeout = ClientTimeout(total=30, connect=10)
- def __init__(self, base_url: str | None = None) -> None:
+ def __init__(
+ self,
+ base_url: str,
+ ) -> None:
"""
Initialize Deribit client.
Args:
+ logger: Logger instance.
base_url: Deribit API base URL. Defaults to settings.
"""
- self.base_url = base_url or settings.deribit_api.base_url
+ self.logger = get_logger()
+ self.base_url = base_url
self._headers = {"Content-Type": "application/json"}
@classmethod
@@ -56,9 +53,11 @@ async def get_session(cls) -> aiohttp.ClientSession:
"""
if cls._session is None or cls._session.closed:
connector = aiohttp.TCPConnector(
- limit=10,
- limit_per_host=2,
+ limit=20,
+ limit_per_host=5,
ttl_dns_cache=300,
+ force_close=False,
+ enable_cleanup_closed=True,
)
cls._session = aiohttp.ClientSession(
connector=connector,
@@ -108,12 +107,7 @@ async def _make_request(
headers=self._headers,
) as response:
response.raise_for_status()
- data = await response.json()
-
- if not isinstance(data, dict):
- raise DeribitAPIError(
- f"Invalid response format: {type(data)}",
- )
+ data: dict[str, Any] = await response.json()
if data.get("error"):
error_msg = data["error"].get(
@@ -130,7 +124,7 @@ async def _make_request(
except ClientResponseError as error:
if error.status >= 500 and attempt < max_retries - 1:
wait_time = 2**attempt
- logger.warning(
+ self.logger.warning(
"Server error %s, retrying in %s seconds...",
error.status,
wait_time,
@@ -146,9 +140,9 @@ async def _make_request(
except ClientError as error:
if attempt < max_retries - 1:
wait_time = 2**attempt
- logger.warning(
+ self.logger.warning(
"Connection error: %s, retrying in %s seconds...",
- str(error),
+ error,
wait_time,
)
await asyncio.sleep(wait_time)
@@ -161,7 +155,7 @@ async def _make_request(
except TimeoutError as error:
if attempt < max_retries - 1:
wait_time = 2**attempt
- logger.warning(
+ self.logger.warning(
"Timeout, retrying in %s seconds...",
wait_time,
)
@@ -212,7 +206,7 @@ async def get_index_price(self, currency: str) -> float:
)
price = float(result["index_price"])
- logger.debug("Retrieved %s price: %s", currency, price)
+ self.logger.debug("Retrieved %s price: %s", currency, price)
return price
except (KeyError, ValueError, TypeError) as error:
@@ -241,10 +235,12 @@ async def get_all_prices(self) -> dict[str, float]:
try:
prices = await asyncio.gather(*tasks, return_exceptions=True)
- result = {}
+ result: dict[str, float] = {}
for currency, price in zip(currencies, prices, strict=False):
if isinstance(price, BaseException):
- logger.error("Failed to get %s price: %s", currency, price)
+ self.logger.error(
+ "Failed to get %s price: %s", currency, price
+ )
continue
result[currency] = price
@@ -252,7 +248,7 @@ async def get_all_prices(self) -> dict[str, float]:
return result
except Exception as error:
- logger.error("Failed to get prices: %s", error)
+ self.logger.error("Failed to get prices: %s", error)
raise
async def health_check(self) -> bool:
diff --git a/app/clients/exceptions.py b/app/clients/exceptions.py
new file mode 100644
index 0000000..9f7af94
--- /dev/null
+++ b/app/clients/exceptions.py
@@ -0,0 +1,7 @@
+class DeribitAPIError(Exception):
+ """Base exception for Deribit API errors."""
+
+ def __init__(self, message: str, status_code: int | None = None):
+ self.message = message
+ self.status_code = status_code
+ super().__init__(message)
diff --git a/app/core/__init__.py b/app/core/__init__.py
index bca35d3..c0fe3f3 100644
--- a/app/core/__init__.py
+++ b/app/core/__init__.py
@@ -6,19 +6,11 @@
centralized logging.
"""
-from .config import get_settings
+from .config import Settings, get_settings
from .logger import get_logger
-logger = get_logger(__name__)
-
-
-try:
- settings = get_settings()
-except Exception as e:
- logger.error("Failed to initialize settings: %s", e)
- raise
-
__all__ = [
+ "Settings",
"get_logger",
"get_settings",
]
diff --git a/app/core/config.py b/app/core/config.py
index 69d17fd..49b85d6 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -5,8 +5,12 @@
environment variable loading, and singleton pattern for global access.
"""
+import logging
+from functools import lru_cache
+from pathlib import Path
from typing import Any, ClassVar
+import toml
from pydantic import BaseModel, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -91,6 +95,17 @@ def is_configured(self) -> bool:
class RedisSettings(BaseModel):
+ """
+ Redis connection configuration settings.
+
+ Attributes:
+ host: Redis server hostname or IP address.
+ port: Redis server port number (1-65535).
+ db: Redis database index (0-15).
+ password: Optional password for Redis authentication.
+ ssl: Whether to use SSL/TLS for the connection.
+ """
+
host: str = "localhost"
port: int = Field(default=6379, ge=1, le=65535)
db: int = Field(default=0, ge=0, le=15)
@@ -118,7 +133,7 @@ class CelerySettings(BaseModel):
task_track_started: Track when task starts execution.
"""
- worker_concurrency: int = Field(default=2, ge=1, le=10)
+ worker_concurrency: int = Field(default=1, ge=1, le=10)
beat_enabled: bool = True
task_track_started: bool = True
@@ -127,22 +142,49 @@ class CelerySettings(BaseModel):
class ApplicationSettings(BaseModel):
"""
- Core application configuration.
+ Core application configuration model that handles application settings
+ and automatically populates metadata from pyproject.toml when not provided.
Attributes:
debug: Enable debug mode for detailed logging and diagnostics.
api_v1_prefix: URL prefix for API version 1 endpoints.
project_name: Display name of the application.
version: Application version string.
+ description: Detailed description of the application.
+ openapi_json: Filename for OpenAPI JSON specification.
+ docs_url: URL path for Swagger UI documentation.
+ redoc_url: URL path for ReDoc documentation.
"""
debug: bool = False
api_v1_prefix: str = "/api/v1"
- project_name: str = "Deribit Price Tracker API"
- version: str = "0.3.0"
+ project_name: str = ""
+ version: str = ""
+ description: str = ""
+ openapi_json: str = "openapi.json"
+ docs_url: str = "/docs"
+ redoc_url: str = "/redoc"
model_config = {"frozen": True}
+ def __init__(self, **data: dict[str, Any]):
+ super().__init__(**data)
+
+ metadata = self._get_metadata()
+
+ if not self.project_name:
+ object.__setattr__(
+ self, "project_name", metadata.get("title", "Unknown")
+ )
+ if not self.version:
+ object.__setattr__(
+ self, "version", metadata.get("version", "Unknown")
+ )
+ if not self.description:
+ object.__setattr__(
+ self, "description", metadata.get("description", "")
+ )
+
@field_validator("api_v1_prefix")
@classmethod
def validate_api_prefix(cls, v: str) -> str:
@@ -169,6 +211,46 @@ def validate_api_prefix(cls, v: str) -> str:
return v
+ @property
+ def metadata(self) -> dict[str, str]:
+ return self._get_metadata()
+
+ def _get_metadata(self) -> dict[str, str]:
+ """
+ Retrieves application metadata from the installed package information.
+
+ Returns:
+ dict:
+ version (str): Current application version (e.g., "0.4.0")
+ description (str): Brief application description
+ title (str): Formatted title (e.g., "Deribit Tracker")
+ """
+ pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
+
+ try:
+ data = toml.load(pyproject_path)
+
+ project_section = data.get("project", {})
+
+ return {
+ "version": project_section.get("version", "Unknown"),
+ "description": project_section.get("description", ""),
+ "title": project_section
+ .get("name", "Unknown")
+ .replace("-", " ")
+ .title(),
+ }
+ except Exception as e:
+ logging.getLogger(__name__).warning(
+ "Could not read pyproject.toml: %s", e
+ )
+
+ return {
+ "title": "Unknown",
+ "version": "Unknown",
+ "description": "",
+ }
+
class CORSSettings(BaseModel):
"""
@@ -182,6 +264,15 @@ class CORSSettings(BaseModel):
"http://localhost:8000",
"http://127.0.0.1:8000",
]
+ allow_credentials: bool = True
+ allow_methods: list[str] = ["GET", "OPTIONS"]
+ allow_headers: list[str] = [
+ "Content-Type",
+ "Authorization",
+ "Accept",
+ "Origin",
+ "X-Requested-With",
+ ]
model_config = {"frozen": True}
@@ -193,9 +284,6 @@ class Settings(BaseSettings):
Consolidates all configuration sections and loads values from
environment variables or .env file. Uses Pydantic for validation
and type safety with nested models.
-
- Environment variables follow the pattern: SECTION__FIELD_NAME
- Example: DATABASE__HOST, DERIBIT_API__CLIENT_ID
"""
_instance: ClassVar["Settings | None"] = None
@@ -236,23 +324,23 @@ def init_instance(cls, **kwargs: Any) -> "Settings":
def _log_initialization(self) -> None:
"""Log settings initialization (excluding sensitive data)."""
- self._logger = get_logger(__name__)
+ logger = get_logger(__name__)
if self.application.debug:
AppLogger.set_level("DEBUG")
- self._logger.debug("Debug logging enabled")
+ logger.debug("Debug logging enabled")
- self._logger.debug("Debug mode: %s", self.application.debug)
- self._logger.info("Application settings initialized")
+ logger.debug("Debug mode: %s", self.application.debug)
+ logger.info("Application settings initialized")
- self._logger.debug(
+ logger.debug(
"Database configured: %s:%s/%s",
self.database.host,
self.database.port,
self.database.db,
)
- self._logger.debug(
+ logger.debug(
"Redis configured: %s:%s (db: %s)",
self.redis.host,
self.redis.port,
@@ -260,22 +348,20 @@ def _log_initialization(self) -> None:
)
if self.deribit_api.is_configured:
- self._logger.info("Deribit API credentials configured")
+ logger.info("Deribit API credentials configured")
else:
- self._logger.warning(
+ logger.info(
"Deribit API credentials not configured - "
"only public endpoints available"
)
-def get_settings(**kwargs) -> Settings:
+@lru_cache
+def get_settings() -> Settings:
"""
Get singleton settings instance.
Returns:
Global Settings instance.
"""
- if Settings._instance is None:
- return Settings.init_instance(**kwargs)
-
- return Settings._instance
+ return Settings.init_instance()
diff --git a/app/core/logger.py b/app/core/logger.py
index b8e74cf..3ecea6b 100644
--- a/app/core/logger.py
+++ b/app/core/logger.py
@@ -8,8 +8,6 @@
import logging
import sys
-from logging.handlers import RotatingFileHandler
-from pathlib import Path
from typing import ClassVar
@@ -67,35 +65,6 @@ def _configure_root_logger(cls) -> None:
console_handler.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
- @classmethod
- def _add_file_handler(
- cls,
- logger: logging.Logger,
- formatter: logging.Formatter,
- ) -> None:
- """
- Add rotating file handler for debug logging.
-
- Args:
- logger: Logger to add handler to.
- formatter: Formatter to use for log messages.
- """
- try:
- log_dir = Path("logs")
- log_dir.mkdir(exist_ok=True)
-
- file_handler = RotatingFileHandler(
- filename=log_dir / "deribit_tracker.log",
- maxBytes=10_485_760,
- backupCount=5,
- encoding="utf-8",
- )
- file_handler.setFormatter(formatter)
- file_handler.setLevel(logging.DEBUG)
- logger.addHandler(file_handler)
- except (PermissionError, OSError) as e:
- logger.warning("Could not create file handler: %s", e)
-
@classmethod
def set_level(
cls,
diff --git a/app/database/__init__.py b/app/database/__init__.py
index 0762c27..169d4aa 100644
--- a/app/database/__init__.py
+++ b/app/database/__init__.py
@@ -5,11 +5,11 @@
"""
from .base import Base
-from .deps import get_db_session
-from .manager import database_manager
+from .manager import DatabaseManager
+from .repository import PriceRepository
__all__ = [
"Base",
- "database_manager",
- "get_db_session",
+ "DatabaseManager",
+ "PriceRepository",
]
diff --git a/app/database/deps.py b/app/database/deps.py
deleted file mode 100644
index b5a7685..0000000
--- a/app/database/deps.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""
-Dependency injection utilities for database access.
-"""
-
-from collections.abc import AsyncGenerator
-
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from .manager import DatabaseManager
-
-
-async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
- """
- Dependency for FastAPI to get database session.
-
- Yields:
- AsyncSession: Database session.
-
- Usage in FastAPI:
- @app.get("/items/")
- async def read_items(session: AsyncSession = Depends(get_db_session)):
- ...
- """
- async with DatabaseManager().get_session() as session:
- yield session
diff --git a/app/database/manager.py b/app/database/manager.py
index f7b5ab0..7f52e0e 100644
--- a/app/database/manager.py
+++ b/app/database/manager.py
@@ -12,7 +12,7 @@
create_async_engine,
)
-from app.core import get_logger, settings
+from app.core import get_logger, get_settings
from .base import Base
@@ -27,6 +27,7 @@ class DatabaseManager:
def __init__(self) -> None:
self._engine: AsyncEngine | None = None
self._session_factory: async_sessionmaker[AsyncSession] | None = None
+ self.settings = get_settings()
def _get_engine(self) -> AsyncEngine:
"""
@@ -40,8 +41,8 @@ def _get_engine(self) -> AsyncEngine:
"""
if self._engine is None:
self._engine = create_async_engine(
- settings.database.async_dsn,
- echo=settings.application.debug,
+ self.settings.database.async_dsn,
+ echo=self.settings.application.debug,
pool_pre_ping=True,
pool_recycle=300,
pool_size=20,
@@ -97,6 +98,23 @@ async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
finally:
await session.close()
+ async def get_session_generator(
+ self,
+ ) -> AsyncGenerator[AsyncSession, None]:
+ """
+ Async generator for FastAPI dependency injection.
+ """
+ session = self._get_session_factory()()
+
+ try:
+ yield session
+ await session.commit()
+ except Exception as error:
+ await session.rollback()
+ raise error
+ finally:
+ await session.close()
+
async def dispose(self) -> None:
"""Close all database connections."""
if self._engine is not None:
@@ -112,8 +130,3 @@ def is_initialized(self) -> bool:
True if engine and session factory are initialized.
"""
return self._engine is not None and self._session_factory is not None
-
-
-database_manager = DatabaseManager()
-database_manager._get_engine()
-database_manager._get_session_factory()
diff --git a/app/database/models.py b/app/database/models.py
index a7422d2..d7254a8 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -3,7 +3,7 @@
"""
from datetime import UTC, datetime
-from typing import Self
+from typing import Any, Self
from sqlalchemy import BigInteger, DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
@@ -77,7 +77,7 @@ def create(
timestamp=timestamp,
)
- def to_dict(self) -> dict:
+ def to_dict(self) -> dict[str, Any]:
"""
Convert PriceTick to dictionary.
diff --git a/app/database/repository.py b/app/database/repository.py
index 491d5d1..6d548fe 100644
--- a/app/database/repository.py
+++ b/app/database/repository.py
@@ -1,24 +1,12 @@
-from collections.abc import AsyncGenerator, Sequence
-from contextlib import asynccontextmanager
+from collections.abc import Sequence
from datetime import datetime
-from sqlalchemy import desc, select
+from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from . import database_manager
from .models import PriceTick
-@asynccontextmanager
-async def get_repository_session() -> AsyncGenerator[AsyncSession, None]:
- """
- Context manager for repository operations.
- Uses existed DatabaseManager.
- """
- async with database_manager.get_session() as session:
- yield session
-
-
class PriceRepository:
"""
Repository for PriceTick database operations.
@@ -169,7 +157,7 @@ async def get_price_closest_to_timestamp(
PriceTick.timestamp <= max_timestamp,
)
.order_by(
- (PriceTick.timestamp - target_timestamp).abs(),
+ func.abs(PriceTick.timestamp - target_timestamp),
)
.limit(1)
)
diff --git a/app/dependencies/__init__.py b/app/dependencies/__init__.py
new file mode 100644
index 0000000..bef78db
--- /dev/null
+++ b/app/dependencies/__init__.py
@@ -0,0 +1,13 @@
+"""
+Centralized dependency injection for FastAPI application.
+"""
+
+from .clients import DeribitClientDep
+from .database import DatabaseSession
+from .services import PriceServiceDep
+
+__all__ = [
+ "DatabaseSession",
+ "DeribitClientDep",
+ "PriceServiceDep",
+]
diff --git a/app/dependencies/clients.py b/app/dependencies/clients.py
new file mode 100644
index 0000000..c973600
--- /dev/null
+++ b/app/dependencies/clients.py
@@ -0,0 +1,50 @@
+"""
+Dependencies for external service clients.
+"""
+
+from functools import lru_cache
+from typing import Annotated
+
+from fastapi import Depends, HTTPException
+
+from app.clients import DeribitClient
+from app.core import get_logger, get_settings
+
+logger = get_logger(__name__)
+
+
+@lru_cache
+def _get_cached_deribit_client(base_url: str) -> DeribitClient:
+ """Internal cached factory for Deribit client."""
+ return DeribitClient(base_url=base_url)
+
+
+async def get_deribit_client() -> DeribitClient:
+ """
+ FastAPI dependency for Deribit client.
+
+ Returns cached client with settings injection.
+ """
+ try:
+ settings = get_settings()
+
+ logger.debug(
+ "Creating Deribit client for %s", settings.deribit_api.base_url
+ )
+ client = _get_cached_deribit_client(
+ base_url=settings.deribit_api.base_url
+ )
+
+ if not await client.health_check():
+ logger.warning("Deribit API is not available")
+
+ return client
+
+ except Exception as e:
+ logger.error("Failed to create Deribit client: %s", e, exc_info=True)
+ raise HTTPException(
+ status_code=503, detail="Deribit service is unavailable"
+ ) from e
+
+
+DeribitClientDep = Annotated[DeribitClient, Depends(get_deribit_client)]
diff --git a/app/dependencies/database.py b/app/dependencies/database.py
new file mode 100644
index 0000000..4b64433
--- /dev/null
+++ b/app/dependencies/database.py
@@ -0,0 +1,26 @@
+"""
+Dependency injection utilities for database access.
+"""
+
+from collections.abc import AsyncGenerator
+from typing import Annotated
+
+from fastapi import Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import DatabaseManager
+
+
+async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
+ """
+ Dependency for FastAPI to get database session.
+
+ Returns an async generator that FastAPI can use.
+ """
+ database_manager = DatabaseManager()
+
+ async for session in database_manager.get_session_generator():
+ yield session
+
+
+DatabaseSession = Annotated[AsyncSession, Depends(get_db_session)]
diff --git a/app/dependencies/services.py b/app/dependencies/services.py
new file mode 100644
index 0000000..af478db
--- /dev/null
+++ b/app/dependencies/services.py
@@ -0,0 +1,24 @@
+from typing import Annotated
+
+from fastapi import Depends
+
+from app.core import get_logger
+from app.database import PriceRepository
+from app.services import PriceService
+
+from .database import DatabaseSession
+
+logger = get_logger(__name__)
+
+
+def get_price_service(
+ session: DatabaseSession,
+) -> PriceService:
+ """
+ Dependency for FastAPI for PriceService.
+ """
+ repository = PriceRepository(session)
+ return PriceService(repository)
+
+
+PriceServiceDep = Annotated[PriceService, Depends(get_price_service)]
diff --git a/app/frontend/__init__.py b/app/frontend/__init__.py
new file mode 100644
index 0000000..845743c
--- /dev/null
+++ b/app/frontend/__init__.py
@@ -0,0 +1,7 @@
+"""
+Frontend module package exports.
+"""
+
+from .routes import router
+
+__all__ = ["router"]
diff --git a/app/frontend/routes.py b/app/frontend/routes.py
new file mode 100644
index 0000000..7614c59
--- /dev/null
+++ b/app/frontend/routes.py
@@ -0,0 +1,52 @@
+"""
+Frontend route handlers.
+
+Serves the HTML interface for the application using Jinja2 templates.
+"""
+
+from pathlib import Path
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from app.core import get_logger, get_settings
+
+logger = get_logger(__name__)
+
+router = APIRouter()
+
+
+def get_templates() -> Jinja2Templates:
+ """
+ Locate and initialize Jinja2 templates.
+ """
+ base_path = Path(__file__).parent
+ return Jinja2Templates(directory=base_path / "templates")
+
+
+@router.get("/", response_class=HTMLResponse)
+async def read_root(request: Request) -> HTMLResponse:
+ """
+ Serve the main dashboard.
+
+ Args:
+ request: The incominng HTTP request.
+
+ Returns:
+ Rendered HTML dashboard.
+ """
+ settings = get_settings()
+ templates = get_templates()
+
+ logger.info("Serving frontend dashboard to client")
+
+ return templates.TemplateResponse(
+ name="index.html",
+ context={
+ "request": request,
+ "project_name": settings.application.project_name,
+ "api_v1_prefix": settings.application.api_v1_prefix,
+ "version": settings.application.version,
+ },
+ )
diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css
new file mode 100644
index 0000000..d85c4a2
--- /dev/null
+++ b/app/frontend/static/css/style.css
@@ -0,0 +1,597 @@
+:root {
+ --neon-pink: #ff2a6d;
+ --neon-blue: #05d9e8;
+ --neon-purple: #d304cc;
+ --dark-bg: #000000;
+ --grid-color: rgba(5, 217, 232, 0.1);
+ --text-main: #e0e0e0;
+ --terminal-font: 'Share Tech Mono', monospace;
+ --card-bg: rgba(1, 1, 43, 0.9);
+ --panel-bg: rgba(0, 0, 20, 0.95);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background-color: var(--dark-bg);
+ color: var(--text-main);
+ font-family: var(--terminal-font);
+ min-height: 100vh;
+ overflow-x: hidden;
+ background-image:
+ linear-gradient(var(--grid-color) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
+ background-size: 30px 30px;
+ position: relative;
+ padding: 20px;
+}
+
+/* CRT Scanline Effect */
+body::before {
+ content: " ";
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ background: linear-gradient(rgba(18, 16, 16, 0) 50%,
+ rgba(0, 0, 0, 0.25) 50%);
+ background-size: 100% 4px;
+ z-index: 1;
+ pointer-events: none;
+}
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ position: relative;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+}
+
+/* Header */
+header {
+ border: 2px solid var(--neon-pink);
+ padding: 25px 35px;
+ background: var(--panel-bg);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 0 25px var(--neon-pink);
+ border-radius: 8px;
+ min-height: 120px;
+}
+
+.header-content {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+h1 {
+ font-size: clamp(2rem, 4vw, 3rem);
+ color: var(--neon-blue);
+ text-shadow: 2px 2px var(--neon-purple);
+ text-transform: uppercase;
+ letter-spacing: 4px;
+ margin: 0;
+}
+
+.system-info {
+ font-size: 0.9rem;
+ color: var(--neon-pink);
+ opacity: 0.9;
+}
+
+.status-badge {
+ border: 1px solid var(--neon-blue);
+ padding: 12px 25px;
+ color: var(--neon-blue);
+ box-shadow: 0 0 10px var(--neon-blue);
+ animation: blink 2s infinite;
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
+/* Dashboard Grid */
+.dashboard {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 30px;
+ margin-bottom: 10px;
+}
+
+.card {
+ background: var(--card-bg);
+ border: 2px solid var(--neon-purple);
+ padding: 25px;
+ position: relative;
+ box-shadow: 0 0 15px rgba(211, 4, 204, 0.4);
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ min-height: 200px;
+}
+
+.card::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ box-shadow: inset 0 0 25px rgba(211, 4, 204, 0.2);
+ pointer-events: none;
+ border-radius: 8px;
+}
+
+.card h2 {
+ color: var(--neon-pink);
+ margin: 0;
+ border-bottom: 2px dashed var(--neon-blue);
+ padding-bottom: 10px;
+ font-size: 1.3rem;
+}
+
+.price-display {
+ font-size: clamp(2rem, 5vw, 3rem);
+ color: #fff;
+ text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
+ min-height: 60px;
+ display: flex;
+ align-items: center;
+}
+
+.ticker-label {
+ font-size: 0.85rem;
+ color: var(--neon-blue);
+ opacity: 0.9;
+}
+
+.timestamp {
+ font-size: 0.8rem;
+ color: var(--neon-pink);
+ opacity: 0.8;
+}
+
+/* API Panel */
+.api-panel {
+ background: var(--panel-bg);
+ border: 2px solid var(--neon-blue);
+ padding: 30px;
+ border-radius: 8px;
+ box-shadow: 0 0 20px rgba(5, 217, 232, 0.3);
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+}
+
+.api-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.endpoint-selector {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.endpoint-selector label {
+ color: var(--neon-blue);
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
+.endpoint-selector select {
+ flex: 1;
+ min-width: 250px;
+}
+
+.parameter-panel {
+ background: rgba(0, 0, 40, 0.6);
+ border: 1px solid var(--neon-purple);
+ padding: 25px;
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.param-row {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+
+.param-row label {
+ color: var(--neon-blue);
+ font-size: 0.9rem;
+ min-width: 180px;
+}
+
+.date-inputs, .pagination-inputs {
+ display: flex;
+ gap: 30px;
+ flex-wrap: wrap;
+}
+
+.date-inputs > div, .pagination-inputs > div {
+ min-width: 200px;
+}
+
+.param-hint {
+ font-size: 0.8rem;
+ color: var(--neon-pink);
+ opacity: 0.8;
+ margin-left: auto;
+}
+
+/* Input Styles */
+select, input, button {
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid var(--neon-blue);
+ color: var(--neon-blue);
+ padding: 12px 20px;
+ font-family: var(--terminal-font);
+ font-size: 0.9rem;
+ transition: all 0.3s;
+ border-radius: 4px;
+ flex: 1;
+ min-width: 200px;
+}
+
+select:focus, input:focus {
+ outline: none;
+ box-shadow: 0 0 15px var(--neon-blue);
+ border-color: var(--neon-blue);
+}
+
+input[type="number"] {
+ min-width: 120px;
+}
+
+input[type="datetime-local"] {
+ min-width: 250px;
+}
+
+/* Button Styles */
+.action-buttons {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+button {
+ cursor: pointer;
+ text-transform: uppercase;
+ white-space: nowrap;
+ flex: none;
+ min-width: 180px;
+}
+
+.btn-primary {
+ background: var(--neon-blue);
+ color: var(--dark-bg);
+ border-color: var(--neon-blue);
+}
+
+.btn-primary:hover {
+ background: #04c8d8;
+ box-shadow: 0 0 25px var(--neon-blue);
+}
+
+.btn-secondary {
+ background: transparent;
+ color: var(--neon-pink);
+ border-color: var(--neon-pink);
+}
+
+.btn-secondary:hover {
+ background: rgba(255, 42, 109, 0.1);
+ box-shadow: 0 0 20px var(--neon-pink);
+}
+
+.btn-tertiary {
+ background: transparent;
+ color: var(--neon-purple);
+ border-color: var(--neon-purple);
+}
+
+.btn-tertiary:hover {
+ background: rgba(211, 4, 204, 0.1);
+ box-shadow: 0 0 20px var(--neon-purple);
+}
+
+/* URL Preview */
+.url-preview {
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid var(--neon-blue);
+ padding: 15px;
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.url-preview label {
+ color: var(--neon-blue);
+ font-size: 0.85rem;
+}
+
+.url-display {
+ background: rgba(0, 0, 30, 0.9);
+ border: 1px dashed var(--neon-purple);
+ padding: 15px;
+ font-size: 0.85rem;
+ word-break: break-all;
+ color: var(--text-main);
+ border-radius: 4px;
+ min-height: 50px;
+ display: flex;
+ align-items: center;
+}
+
+/* Results Container */
+.results-container {
+ background: rgba(0, 0, 0, 0.8);
+ border: 1px solid var(--neon-purple);
+ padding: 25px;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.results-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 2px solid var(--neon-blue);
+ padding-bottom: 15px;
+}
+
+.results-header h3 {
+ margin: 0;
+ font-size: 1.2rem;
+}
+
+.result-meta {
+ color: var(--neon-pink);
+ font-size: 0.85rem;
+ opacity: 0.9;
+}
+
+/* Table Styles */
+.table-container {
+ overflow-x: auto;
+ border: 1px solid var(--neon-blue);
+ border-radius: 4px;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 800px;
+}
+
+th {
+ text-align: left;
+ color: var(--neon-pink);
+ border-bottom: 2px solid var(--neon-purple);
+ padding: 15px;
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ background: rgba(0, 0, 30, 0.8);
+}
+
+td {
+ padding: 15px;
+ border-bottom: 1px solid rgba(5, 217, 232, 0.3);
+ color: var(--text-main);
+ font-size: 0.9rem;
+}
+
+tr:hover td {
+ background: rgba(5, 217, 232, 0.1);
+ color: #fff;
+}
+
+/* Stats Container */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ padding: 20px;
+}
+
+.stat-card {
+ background: rgba(0, 0, 30, 0.8);
+ border: 1px solid var(--neon-blue);
+ padding: 20px;
+ border-radius: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ text-align: center;
+}
+
+.stat-label {
+ color: var(--neon-pink);
+ font-size: 0.85rem;
+ opacity: 0.9;
+}
+
+.stat-value {
+ color: var(--neon-blue);
+ font-size: 1.5rem;
+ font-weight: bold;
+ text-shadow: 0 0 10px rgba(5, 217, 232, 0.5);
+}
+
+/* Single Result */
+.single-result-card {
+ background: rgba(0, 0, 30, 0.8);
+ border: 2px solid var(--neon-blue);
+ padding: 30px;
+ border-radius: 8px;
+}
+
+.single-result-card h4 {
+ color: var(--neon-pink);
+ margin-bottom: 25px;
+ border-bottom: 1px dashed var(--neon-blue);
+ padding-bottom: 10px;
+ font-size: 1.1rem;
+}
+
+.single-result-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 20px;
+}
+
+.single-result-grid > div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 4px;
+}
+
+.single-label {
+ color: var(--neon-blue);
+ font-size: 0.9rem;
+}
+
+.single-result-grid span:last-child {
+ color: var(--text-main);
+ font-size: 1rem;
+ font-weight: bold;
+}
+
+/* Animations */
+@keyframes blink {
+ 0% {
+ opacity: 1;
+ box-shadow: 0 0 10px var(--neon-blue);
+ }
+ 50% {
+ opacity: 0.6;
+ box-shadow: 0 0 5px var(--neon-blue);
+ }
+ 100% {
+ opacity: 1;
+ box-shadow: 0 0 10px var(--neon-blue);
+ }
+}
+
+/* Responsive Design */
+@media (max-width: 1200px) {
+ .container {
+ gap: 20px;
+ }
+
+ header {
+ padding: 20px 25px;
+ }
+
+ .api-panel {
+ padding: 25px;
+ }
+}
+
+@media (max-width: 768px) {
+ body {
+ padding: 15px;
+ }
+
+ .dashboard {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+
+ header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 20px;
+ padding: 20px;
+ }
+
+ .status-badge {
+ align-self: flex-start;
+ }
+
+ .date-inputs, .pagination-inputs {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .param-row {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .param-row label {
+ min-width: auto;
+ }
+
+ select, input, button {
+ min-width: 100%;
+ }
+
+ .action-buttons {
+ flex-direction: column;
+ }
+
+ .results-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .single-result-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 480px) {
+ body {
+ padding: 10px;
+ }
+
+ h1 {
+ font-size: 1.8rem;
+ letter-spacing: 2px;
+ }
+
+ .card {
+ padding: 20px;
+ }
+
+ .api-panel {
+ padding: 20px;
+ }
+
+ .parameter-panel {
+ padding: 20px;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html
new file mode 100644
index 0000000..02e341d
--- /dev/null
+++ b/app/frontend/templates/index.html
@@ -0,0 +1,520 @@
+
+
+
+
+
+
+ {{ project_name }} // DASHBOARD
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>> BTC_USD
+
LOADING...
+
INDEX_PRICE // LIVE
+
--:--:--
+
+
+
+
>> ETH_USD
+
LOADING...
+
INDEX_PRICE // LIVE
+
--:--:--
+
+
+
+
+
+
+ >> API_INTERFACE
+
+
+
+
+
+ SELECT_ENDPOINT:
+
+ GET_ALL_PRICES
+ GET_LATEST_PRICE
+ GET_PRICE_AT_TIMESTAMP
+ GET_CLOSEST_PRICE
+ GET_PRICES_BY_DATE
+ GET_STATISTICS
+
+
+
+
+
+
+
+ TICKER:
+
+ BTC_USD
+ ETH_USD
+
+
+
+
+
+
TIMESTAMP (UNIX):
+
+
Current: 1704067200
+
+
+
+
+
+
+
+
+
+
+ MAX_DIFF_SECONDS:
+
+
+
+
+
+
+ EXECUTE_QUERY
+ RESET_FORM
+ COPY_CURL
+
+
+
+
+
REQUEST_URL:
+
/api/v1/prices/?ticker=btc_usd&limit=10
+
+
+
+
+
+
+
+
+
+
+
+
+ TIMESTAMP (UTC)
+ TIMESTAMP (UNIX)
+ TICKER
+ PRICE (USD)
+ RECORD_ID
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SINGLE_PRICE_RECORD
+
+
+ TICKER:
+ --
+
+
+ PRICE:
+ --
+
+
+ TIMESTAMP:
+ --
+
+
+ RECORD_ID:
+ --
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/main.py b/app/main.py
index accc8a7..0f2a5e7 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,49 +1,77 @@
-from contextlib import asynccontextmanager
-
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
-from . import description, title, version
-from .core import get_logger, settings
+from .api import (
+ api_v1_router,
+ health_check_router,
+ register_exception_handlers,
+)
+from .core import get_logger, get_settings
+from .frontend import router as frontend_router
-logger = get_logger(__name__)
+def create_app() -> FastAPI:
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- logger.info("Starting Deribit Price Tracker API...")
- yield
- logger.info("Shutting down Deribit Price Tracker API...")
+ logger = get_logger(__name__)
+ try:
+ settings = get_settings()
+ app_config = settings.application
+ cors_config = settings.cors
+ openapi_url = "/".join([
+ app_config.api_v1_prefix,
+ app_config.openapi_json,
+ ])
-def create_app() -> FastAPI:
- app = FastAPI(
- title=title,
- version=version,
- description=description,
- lifespan=lifespan,
- )
- app.add_middleware(
- CORSMiddleware,
- allow_origins=settings.cors.origins,
- allow_credentials=True,
- allow_methods=["GET", "OPTIONS"],
- allow_headers=["*"],
- )
- return app
-
-
-try:
- app: FastAPI = create_app()
-except Exception as e:
- logger.info("FastAPI app creation error", e)
-
-
-@app.get("/")
-async def root():
- return {"message": "root"}
-
-
-@app.get("/health")
-async def health_check():
- return {"status": "healthy"}
+ app = FastAPI(
+ title=app_config.project_name,
+ version=app_config.version,
+ description=app_config.description,
+ docs_url=app_config.docs_url,
+ redoc_url=app_config.redoc_url,
+ debug=app_config.debug,
+ openapi_url=openapi_url,
+ )
+
+ register_exception_handlers(app)
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=cors_config.origins,
+ allow_credentials=cors_config.allow_credentials,
+ allow_methods=cors_config.allow_methods,
+ allow_headers=cors_config.allow_headers,
+ )
+
+ app.include_router(frontend_router)
+ app.include_router(health_check_router)
+ app.include_router(
+ api_v1_router,
+ prefix=app_config.api_v1_prefix,
+ )
+
+ app.mount(
+ "/static",
+ StaticFiles(directory="app/frontend/static"),
+ name="static",
+ )
+
+ logger.info(
+ "FastAPI application successfully initialized: %s",
+ app_config.project_name,
+ )
+
+ return app
+
+ except ValueError as e:
+ logger.error("Configuration error: %s", e, exc_info=True)
+ raise
+ except Exception as e:
+ logger.error(
+ "Unexpected error creating FastAPI app: %s", e, exc_info=True
+ )
+ raise
+
+
+app = create_app()
diff --git a/app/services/__init__.py b/app/services/__init__.py
index e69de29..bd7d6cc 100644
--- a/app/services/__init__.py
+++ b/app/services/__init__.py
@@ -0,0 +1,7 @@
+"""
+Services module package exports.
+"""
+
+from .price_service import PriceService
+
+__all__ = ["PriceService"]
diff --git a/app/services/price_service.py b/app/services/price_service.py
index e69de29..18f9603 100644
--- a/app/services/price_service.py
+++ b/app/services/price_service.py
@@ -0,0 +1,303 @@
+"""
+Price service layer for business logic.
+
+This service orchestrates between API layer and repository,
+handling business rules, validation, and data transformation.
+"""
+
+from collections.abc import Sequence
+from datetime import UTC, datetime
+from typing import Any
+
+from app.core import get_logger
+from app.database.models import PriceTick
+from app.database.repository import PriceRepository
+
+logger = get_logger(__name__)
+
+
+class PriceService:
+ """
+ Service for price-related business operations.
+
+ Provides high-level methods for API endpoints, handling
+ business logic, validation, and error handling.
+ """
+
+ def __init__(self, repository: PriceRepository) -> None:
+ """
+ Initialize price service.
+
+ Args:
+ repository: PriceRepository instance.
+ """
+ self.repository = repository
+
+ async def get_all_prices(
+ self,
+ ticker: str,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> Sequence[PriceTick]:
+ """
+ Get all price records for a ticker with pagination.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ limit: Maximum number of records to return.
+ offset: Number of records to skip.
+
+ Returns:
+ List of PriceTick records.
+
+ Raises:
+ ValueError: If ticker is not supported.
+ """
+ self._validate_ticker(ticker)
+ logger.debug(
+ "Getting prices for %s (limit: %s, offset: %s)",
+ ticker,
+ limit,
+ offset,
+ )
+
+ return await self.repository.get_all_by_ticker(
+ ticker=ticker,
+ limit=limit,
+ offset=offset,
+ )
+
+ async def get_latest_price(self, ticker: str) -> PriceTick | None:
+ """
+ Get latest price for a ticker.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+
+ Returns:
+ Latest PriceTick or None if not found.
+ """
+ self._validate_ticker(ticker)
+ logger.debug("Getting latest price for %s", ticker)
+
+ return await self.repository.get_latest_price(ticker)
+
+ async def get_price_at_timestamp(
+ self,
+ ticker: str,
+ timestamp: int,
+ ) -> PriceTick | None:
+ """
+ Get price at exact timestamp.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ timestamp: UNIX timestamp.
+
+ Returns:
+ PriceTick at exact timestamp or None if not found.
+ """
+ self._validate_ticker(ticker)
+ logger.debug(
+ "Getting price for %s at timestamp %s",
+ ticker,
+ timestamp,
+ )
+
+ return await self.repository.get_price_at_timestamp(
+ ticker=ticker,
+ timestamp=timestamp,
+ )
+
+ async def get_price_closest_to_timestamp(
+ self,
+ ticker: str,
+ timestamp: int,
+ max_difference_seconds: int = 60,
+ ) -> PriceTick | None:
+ """
+ Get price closest to target timestamp.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ timestamp: Target UNIX timestamp.
+ max_difference_seconds: Maximum time difference in seconds.
+
+ Returns:
+ Closest PriceTick or None if not found.
+ """
+ self._validate_ticker(ticker)
+ logger.debug(
+ "Getting price closest to timestamp %s for %s (±%s seconds)",
+ timestamp,
+ ticker,
+ max_difference_seconds,
+ )
+
+ return await self.repository.get_price_closest_to_timestamp(
+ ticker=ticker,
+ target_timestamp=timestamp,
+ max_difference_seconds=max_difference_seconds,
+ )
+
+ async def get_prices_by_date_range(
+ self,
+ ticker: str,
+ start_date: datetime | None = None,
+ end_date: datetime | None = None,
+ ) -> Sequence[PriceTick]:
+ """
+ Get prices within date range.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ start_date: Start datetime (inclusive).
+ end_date: End datetime (inclusive).
+
+ Returns:
+ List of PriceTick records within date range.
+ """
+ self._validate_ticker(ticker)
+
+ if start_date is None:
+ start_date = datetime.now(UTC).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
+ if end_date is None:
+ end_date = datetime.now(UTC)
+
+ if start_date > end_date:
+ start_date, end_date = end_date, start_date
+
+ logger.debug(
+ "Getting prices for %s from %s to %s",
+ ticker,
+ start_date,
+ end_date,
+ )
+
+ return await self.repository.get_prices_by_date_range(
+ ticker=ticker,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ async def get_price_statistics(
+ self,
+ ticker: str,
+ target_timestamp: int | None = None,
+ ) -> dict[str, Any]:
+ """
+ Get comprehensive price statistics.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ target_timestamp: Optional timestamp for specific price.
+
+ Returns:
+ Dictionary with price statistics.
+ """
+ self._validate_ticker(ticker)
+
+ latest_price = await self.repository.get_latest_price(ticker)
+ all_prices = await self.repository.get_all_by_ticker(ticker)
+
+ price_at_time = None
+ closest_price = None
+
+ if target_timestamp is not None:
+ price_at_time_tick = await self.repository.get_price_at_timestamp(
+ ticker=ticker,
+ timestamp=target_timestamp,
+ )
+ if price_at_time_tick:
+ price_at_time = price_at_time_tick.price
+
+ closest_price_tick = (
+ await self.repository.get_price_closest_to_timestamp(
+ ticker=ticker,
+ target_timestamp=target_timestamp,
+ )
+ )
+ if closest_price_tick:
+ closest_price = closest_price_tick.price
+
+ prices = [tick.price for tick in all_prices]
+
+ if not prices:
+ return {
+ "ticker": ticker,
+ "latest_price": None,
+ "latest_timestamp": None,
+ "count": 0,
+ "price_at_time": None,
+ "closest_price": None,
+ "min_price": None,
+ "max_price": None,
+ "avg_price": None,
+ }
+
+ return {
+ "ticker": ticker,
+ "latest_price": latest_price.price if latest_price else None,
+ "latest_timestamp": (
+ latest_price.timestamp if latest_price else None
+ ),
+ "count": len(prices),
+ "price_at_time": price_at_time,
+ "closest_price": closest_price,
+ "min_price": min(prices) if prices else None,
+ "max_price": max(prices) if prices else None,
+ "avg_price": (sum(prices) / len(prices) if prices else None),
+ }
+
+ async def create_price_tick(
+ self,
+ ticker: str,
+ price: float,
+ timestamp: int | None = None,
+ ) -> PriceTick:
+ """
+ Create new price tick record.
+
+ Args:
+ ticker: Cryptocurrency ticker symbol.
+ price: Current price in USD.
+ timestamp: UNIX timestamp (defaults to current time).
+
+ Returns:
+ Created PriceTick instance.
+
+ Raises:
+ ValueError: If price is negative.
+ """
+ self._validate_ticker(ticker)
+
+ if price < 0:
+ raise ValueError(f"Price cannot be negative: {price}")
+
+ logger.debug("Creating price tick for %s: %s", ticker, price)
+
+ return await self.repository.create(
+ ticker=ticker,
+ price=price,
+ timestamp=timestamp,
+ )
+
+ @staticmethod
+ def _validate_ticker(ticker: str) -> None:
+ """
+ Validate ticker symbol.
+
+ Args:
+ ticker: Ticker to validate.
+
+ Raises:
+ ValueError: If ticker is not supported.
+ """
+ normalized_ticker = ticker.strip().lower()
+ if normalized_ticker not in {"btc_usd", "eth_usd"}:
+ raise ValueError(
+ f"Unsupported ticker: {ticker}. "
+ f"Supported: 'btc_usd', 'eth_usd'"
+ )
diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py
index 290480b..5e0e25a 100644
--- a/app/tasks/__init__.py
+++ b/app/tasks/__init__.py
@@ -4,33 +4,6 @@
This module ensures database is initialized before any task runs.
"""
-from celery.signals import worker_ready
-
-from app.core import get_logger
-from app.core.celery import celery_app
-from app.database.manager import database_manager
-
-logger = get_logger(__name__)
-
-
-@worker_ready.connect
-def initialize_database_on_worker_start(**kwargs):
- """
- Initialize database connection when Celery worker starts.
-
- This ensures database_manager is ready before any tasks execute.
- """
- logger.info("Initializing database for Celery worker...")
-
- try:
- if not database_manager.is_initialized():
- database_manager._get_engine()
- logger.info("Database initialized for Celery worker")
- else:
- logger.info("Database already initialized")
- except Exception as e:
- logger.error("Failed to initialize database for Celery worker: %s", e)
- raise
-
+from .celery_application import celery_app
__all__ = ["celery_app"]
diff --git a/app/tasks/celery_app.py b/app/tasks/celery_app.py
deleted file mode 100644
index f4d4984..0000000
--- a/app/tasks/celery_app.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""
-Celery application entry point.
-
-This module exports the Celery app instance for use with
-celery command line interface.
-"""
-
-from app.core.celery import celery_app
-
-__all__ = ["celery_app"]
diff --git a/app/core/celery.py b/app/tasks/celery_application.py
similarity index 89%
rename from app/core/celery.py
rename to app/tasks/celery_application.py
index e409e49..95562c4 100644
--- a/app/core/celery.py
+++ b/app/tasks/celery_application.py
@@ -1,13 +1,11 @@
"""
-Celery configuration for background task processing.
-
Configures Celery with Redis broker and result backend,
with proper connection pooling and error handling.
"""
from celery import Celery
-from . import get_logger, settings
+from app.core import get_logger, get_settings
logger = get_logger(__name__)
@@ -19,6 +17,8 @@ def create_celery_app() -> Celery:
Returns:
Configured Celery instance with Redis broker.
"""
+ settings = get_settings()
+
celery_app = Celery(
"deribit_tracker",
broker=settings.redis.url,
@@ -61,7 +61,4 @@ def create_celery_app() -> Celery:
return celery_app
-try:
- celery_app = create_celery_app()
-except Exception as e:
- logger.info("Failed to create celery app", e)
+celery_app = create_celery_app()
diff --git a/app/tasks/dependencies.py b/app/tasks/dependencies.py
new file mode 100644
index 0000000..95e5ba6
--- /dev/null
+++ b/app/tasks/dependencies.py
@@ -0,0 +1,22 @@
+"""
+Celery task dependencies.
+"""
+
+from functools import lru_cache
+
+from app.clients import DeribitClient
+from app.core import get_settings
+from app.database import DatabaseManager
+
+
+@lru_cache
+def get_database_manager_tasks() -> DatabaseManager:
+ """Get cached database manager."""
+ return DatabaseManager()
+
+
+@lru_cache
+def get_deribit_client_tasks() -> DeribitClient:
+ """Get Deribit client for Celery tasks."""
+ settings = get_settings()
+ return DeribitClient(base_url=settings.deribit_api.base_url)
diff --git a/app/tasks/price_collection.py b/app/tasks/price_collection.py
index 7a87cef..fb304aa 100644
--- a/app/tasks/price_collection.py
+++ b/app/tasks/price_collection.py
@@ -1,20 +1,19 @@
"""
Celery tasks for periodic price collection from Deribit.
-
-Tasks run in background using asyncio pool for efficient
-async execution.
"""
import asyncio
from datetime import UTC, datetime
from typing import Any
-from app.clients import DeribitClient
-from app.core import get_logger
-from app.database.manager import database_manager
+from celery import Task
+from redis.asyncio.client import Redis
+
+from app.core import get_logger, get_settings
from app.database.repository import PriceRepository
from . import celery_app
+from .dependencies import get_database_manager_tasks, get_deribit_client_tasks
logger = get_logger(__name__)
@@ -31,7 +30,8 @@ async def collect_price_for_ticker(ticker: str) -> dict[str, Any] | None:
"""
from datetime import UTC, datetime
- deribit_client = DeribitClient()
+ deribit_client = get_deribit_client_tasks()
+ database_manager = get_database_manager_tasks()
timestamp = int(datetime.now(UTC).timestamp())
try:
@@ -58,7 +58,7 @@ async def collect_price_for_ticker(ticker: str) -> dict[str, Any] | None:
}
except Exception as error:
- logger.error("Failed to collect %s price: %s", ticker, str(error))
+ logger.error("Failed to collect %s price: %s", ticker, error)
return {
"ticker": ticker,
"error": str(error),
@@ -75,7 +75,7 @@ async def collect_price_for_ticker(ticker: str) -> dict[str, Any] | None:
acks_late=True,
ignore_result=False,
)
-def collect_all_prices(self):
+def collect_all_prices(self: Task) -> dict[str, Any]: # type: ignore
"""
Celery task to collect prices for all supported tickers.
@@ -93,19 +93,19 @@ def collect_all_prices(self):
asyncio.set_event_loop(loop)
tickers = ["btc_usd", "eth_usd"]
- tasks = []
+ tasks: list[tuple[str, Any]] = []
for ticker in tickers:
task = loop.create_task(collect_price_for_ticker(ticker))
tasks.append((ticker, task))
- results = []
+ results: list[dict[str, Any]] = []
for ticker, task in tasks:
try:
- result = loop.run_until_complete(task)
- results.append(result)
+ task_result = loop.run_until_complete(task)
+ results.append(task_result)
except Exception as error:
- logger.error("Failed to collect %s price: %s", ticker, str(error))
+ logger.error("Failed to collect %s price: %s", ticker, error)
results.append(
{"ticker": ticker, "error": str(error), "success": False},
)
@@ -113,16 +113,21 @@ def collect_all_prices(self):
successful = [r for r in results if r and r.get("success")]
failed = [r for r in results if r and not r.get("success")]
- if failed and self.request.retries < self.max_retries:
+ if (
+ failed
+ and self.max_retries is not None
+ and self.request.retries < self.max_retries
+ ):
logger.warning("Some collections failed, retrying...")
raise self.retry(countdown=30)
- return {
+ result: dict[str, Any] = {
"successful": len(successful),
"failed": len(failed),
"results": results,
"timestamp": int(datetime.now(UTC).timestamp()),
}
+ return result
@celery_app.task(
@@ -151,9 +156,7 @@ async def collect_single_price(ticker: str) -> dict[str, Any] | None:
return result
except Exception as error:
- logger.error("Failed to collect %s price: %s", ticker, str(error))
-
- from datetime import UTC, datetime
+ logger.error("Failed to collect %s price: %s", ticker, error)
return {
"ticker": ticker,
@@ -167,53 +170,59 @@ async def collect_single_price(ticker: str) -> dict[str, Any] | None:
name="app.tasks.price_collection.health_check",
ignore_result=False,
)
-async def health_check() -> dict[str, Any]:
+def health_check() -> dict[str, Any]:
"""
- Async health check task for price collection system.
-
- Returns:
- Dictionary with system health status.
+ Synchronous health check task for price collection system.
"""
- from datetime import UTC, datetime
-
- try:
- deribit_client = DeribitClient()
- api_healthy = await deribit_client.health_check()
- db_healthy = database_manager.is_initialized()
-
- import redis.asyncio as redis
-
- from app.core import settings
-
- redis_healthy = False
+ async def _run_health_check() -> dict[str, Any]:
try:
- redis_client = redis.from_url(
- settings.redis.url,
- decode_responses=True,
- )
- await redis_client.ping()
- redis_healthy = True
- await redis_client.close()
- except Exception as e:
- logger.warning("Redis health check failed: %s", e)
+ deribit_client = get_deribit_client_tasks()
+ database_manager = get_database_manager_tasks()
+ api_healthy = await deribit_client.health_check()
+ db_healthy = database_manager.is_initialized()
+ settings = get_settings()
+
+ redis_healthy = False
+ try:
+ redis_client = Redis.from_url(
+ settings.redis.url,
+ decode_responses=True,
+ )
+ await redis_client.ping()
+ redis_healthy = True
+ await redis_client.close()
+ except Exception as e:
+ logger.warning("Redis health check failed: %s", e)
+
+ overall_healthy = all([api_healthy, db_healthy, redis_healthy])
- overall_healthy = all([api_healthy, db_healthy, redis_healthy])
+ return {
+ "status": "healthy" if overall_healthy else "unhealthy",
+ "components": {
+ "deribit_api": (
+ "available" if api_healthy else "unavailable"
+ ),
+ "database": (
+ "initialized" if db_healthy else "not_initialized"
+ ),
+ "redis": "available" if redis_healthy else "unavailable",
+ },
+ "timestamp": int(datetime.now(UTC).timestamp()),
+ }
- return {
- "status": "healthy" if overall_healthy else "unhealthy",
- "components": {
- "deribit_api": "available" if api_healthy else "unavailable",
- "database": "initialized" if db_healthy else "not_initialized",
- "redis": "available" if redis_healthy else "unavailable",
- },
- "timestamp": int(datetime.now(UTC).timestamp()),
- }
+ except Exception as error:
+ logger.error("Health check failed: %s", error)
+ return {
+ "status": "unhealthy",
+ "error": str(error),
+ "timestamp": int(datetime.now(UTC).timestamp()),
+ }
- except Exception as error:
- logger.error("Health check failed: %s", str(error))
- return {
- "status": "unhealthy",
- "error": str(error),
- "timestamp": int(datetime.now(UTC).timestamp()),
- }
+ try:
+ loop = asyncio.get_event_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ return loop.run_until_complete(_run_health_check())
diff --git a/docker-compose.yml b/docker-compose.yml
index af3cbfe..9f91a05 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -71,7 +71,7 @@ services:
DATABASE__DB: ${DATABASE__DB:-deribit_tracker}
REDIS__HOST: redis
REDIS__PORT: 6379
- CELERY__WORKER_CONCURRENCY: ${CELERY__WORKER_CONCURRENCY:-2}
+ CELERY__WORKER_CONCURRENCY: ${CELERY__WORKER_CONCURRENCY:-1}
CELERY__BEAT_ENABLED: ${CELERY__BEAT_ENABLED:-false}
CELERY__TASK_TRACK_STARTED: ${CELERY__TASK_TRACK_STARTED:-true}
depends_on:
@@ -84,9 +84,9 @@ services:
command: >
poetry run celery -A app.tasks.celery_app worker
--loglevel=info
- --concurrency=${CELERY__WORKER_CONCURRENCY:-2}
+ --concurrency=${CELERY__WORKER_CONCURRENCY:-1}
--pool=solo
- --queues=price_collection,celery,default
+ --queues=price_collection
env_file:
- .env
@@ -113,7 +113,6 @@ services:
poetry run celery -A app.tasks.celery_app beat
--loglevel=info
--scheduler=celery.beat:PersistentScheduler
-
env_file:
- .env
diff --git a/poetry.lock b/poetry.lock
index 6bdba5a..962a7e8 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -456,6 +456,21 @@ yaml = ["kombu[yaml]"]
zookeeper = ["kazoo (>=1.3.1)"]
zstd = ["zstandard (==0.23.0)"]
+[[package]]
+name = "celery-types"
+version = "0.24.0"
+description = "Type stubs for Celery and its related packages"
+optional = false
+python-versions = "<4,>=3.10"
+groups = ["dev"]
+files = [
+ {file = "celery_types-0.24.0-py3-none-any.whl", hash = "sha256:a21e04681e68719a208335e556a79909da4be9c5e0d6d2fd0dd4c5615954b3fd"},
+ {file = "celery_types-0.24.0.tar.gz", hash = "sha256:c93fbcd0b04a9e9c2f55d5540aca4aa1ea4cc06a870c0c8dee5062fdd59663fe"},
+]
+
+[package.dependencies]
+typing-extensions = ">=4.15.0,<5"
+
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -474,7 +489,7 @@ version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
-groups = ["dev"]
+groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
@@ -783,104 +798,104 @@ files = [
[[package]]
name = "coverage"
-version = "7.13.1"
+version = "7.13.2"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
- {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"},
- {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"},
- {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"},
- {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"},
- {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"},
- {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"},
- {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"},
- {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"},
- {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"},
- {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"},
- {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"},
- {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"},
- {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"},
- {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"},
- {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"},
- {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"},
- {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"},
- {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"},
- {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"},
- {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"},
- {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"},
- {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"},
- {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"},
- {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"},
- {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"},
- {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"},
- {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"},
- {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"},
- {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"},
- {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"},
- {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"},
- {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"},
- {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"},
- {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"},
- {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"},
- {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"},
- {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"},
- {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"},
- {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"},
- {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"},
- {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"},
- {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"},
- {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"},
- {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"},
- {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"},
- {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"},
- {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"},
- {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"},
- {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"},
- {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"},
- {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"},
- {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"},
- {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"},
- {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"},
- {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"},
- {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"},
- {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"},
- {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"},
- {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"},
- {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"},
- {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"},
- {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"},
- {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"},
- {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"},
- {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"},
- {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"},
- {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"},
- {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"},
- {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"},
- {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"},
- {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"},
- {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"},
- {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"},
- {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"},
- {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"},
- {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"},
- {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"},
- {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"},
- {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"},
- {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"},
- {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"},
- {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"},
- {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"},
- {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"},
- {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"},
- {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"},
- {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"},
- {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"},
- {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"},
- {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"},
- {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"},
- {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"},
+ {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"},
+ {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"},
+ {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"},
+ {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"},
+ {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"},
+ {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"},
+ {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"},
+ {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"},
+ {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"},
+ {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"},
+ {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"},
+ {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"},
+ {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"},
+ {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"},
+ {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"},
+ {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"},
+ {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"},
+ {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"},
+ {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"},
+ {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"},
+ {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"},
+ {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"},
+ {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"},
+ {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"},
+ {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"},
+ {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"},
+ {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"},
+ {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"},
+ {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"},
+ {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"},
+ {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"},
+ {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"},
+ {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"},
+ {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"},
+ {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"},
+ {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"},
+ {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"},
+ {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"},
+ {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"},
+ {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"},
+ {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"},
+ {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"},
+ {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"},
+ {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"},
+ {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"},
+ {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"},
+ {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"},
+ {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"},
+ {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"},
+ {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"},
]
[package.extras]
@@ -888,66 +903,61 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
-version = "46.0.3"
+version = "46.0.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
- {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
- {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
- {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
- {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
- {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
- {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
- {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
- {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
- {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
- {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
- {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
+ {file = "cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32"},
+ {file = "cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616"},
+ {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0"},
+ {file = "cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0"},
+ {file = "cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5"},
+ {file = "cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b"},
+ {file = "cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e"},
+ {file = "cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f"},
+ {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82"},
+ {file = "cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c"},
+ {file = "cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061"},
+ {file = "cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7"},
+ {file = "cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b"},
+ {file = "cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019"},
+ {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4"},
+ {file = "cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b"},
+ {file = "cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc"},
+ {file = "cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947"},
+ {file = "cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3"},
+ {file = "cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59"},
]
[package.dependencies]
@@ -960,7 +970,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==46.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -1945,14 +1955,14 @@ files = [
[[package]]
name = "marshmallow"
-version = "4.2.0"
+version = "4.2.1"
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
- {file = "marshmallow-4.2.0-py3-none-any.whl", hash = "sha256:1dc369bd13a8708a9566d6f73d1db07d50142a7580f04fd81e1c29a4d2e10af4"},
- {file = "marshmallow-4.2.0.tar.gz", hash = "sha256:908acabd5aa14741419d3678d3296bda6abe28a167b7dcd05969ceb8256943ac"},
+ {file = "marshmallow-4.2.1-py3-none-any.whl", hash = "sha256:d82b1a83cfbb4667d050850fbed4e9d4435576cb95f5ff37894f375dce201768"},
+ {file = "marshmallow-4.2.1.tar.gz", hash = "sha256:4d1d66189c8d279ca73a6b0599d74117e5f8a3830b5cd766b75c2bb08e3464e7"},
]
[package.extras]
@@ -1974,158 +1984,158 @@ files = [
[[package]]
name = "multidict"
-version = "6.7.0"
+version = "6.7.1"
description = "multidict implementation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"},
- {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"},
- {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"},
- {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"},
- {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"},
- {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"},
- {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"},
- {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"},
- {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"},
- {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"},
- {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"},
- {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"},
- {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"},
- {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"},
- {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"},
- {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"},
- {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"},
- {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"},
- {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"},
- {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"},
- {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"},
- {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"},
- {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"},
- {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"},
- {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"},
- {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"},
- {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"},
- {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"},
- {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"},
- {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"},
- {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"},
- {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"},
- {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"},
- {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"},
- {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"},
- {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"},
- {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"},
- {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"},
- {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"},
- {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"},
- {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"},
- {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"},
- {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"},
- {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"},
- {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"},
- {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"},
- {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"},
- {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"},
- {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"},
- {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"},
- {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"},
- {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"},
- {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"},
- {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"},
- {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"},
- {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"},
- {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"},
- {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"},
- {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"},
- {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"},
- {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"},
- {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"},
- {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"},
- {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"},
- {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"},
- {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"},
+ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"},
+ {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"},
+ {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"},
+ {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"},
+ {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"},
+ {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"},
+ {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"},
+ {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"},
+ {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"},
+ {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"},
+ {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"},
+ {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"},
+ {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"},
+ {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"},
+ {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"},
+ {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"},
+ {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"},
+ {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"},
+ {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"},
+ {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"},
+ {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"},
+ {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"},
+ {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"},
+ {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"},
+ {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"},
+ {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"},
+ {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"},
+ {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"},
+ {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"},
+ {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"},
+ {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"},
+ {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"},
+ {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"},
+ {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"},
+ {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"},
+ {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"},
+ {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"},
+ {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"},
+ {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"},
+ {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"},
+ {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"},
+ {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"},
+ {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"},
+ {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"},
+ {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"},
+ {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"},
+ {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"},
+ {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"},
+ {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"},
+ {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"},
+ {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"},
+ {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"},
+ {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"},
+ {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"},
+ {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"},
+ {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"},
+ {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"},
+ {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"},
+ {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"},
+ {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"},
+ {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"},
+ {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"},
+ {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"},
+ {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"},
+ {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"},
+ {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"},
]
[[package]]
@@ -2253,14 +2263,14 @@ files = [
[[package]]
name = "pathspec"
-version = "1.0.3"
+version = "1.0.4"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c"},
- {file = "pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d"},
+ {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
+ {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
]
[package.extras]
@@ -2598,7 +2608,7 @@ version = "3.0"
description = "C parser in Python"
optional = false
python-versions = ">=3.10"
-groups = ["dev"]
+groups = ["main", "dev"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
@@ -2826,24 +2836,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
-[[package]]
-name = "pyjwt"
-version = "2.10.1"
-description = "JSON Web Token implementation in Python"
-optional = false
-python-versions = ">=3.9"
-groups = ["main"]
-files = [
- {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
- {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
-]
-
-[package.extras]
-crypto = ["cryptography (>=3.4.0)"]
-dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
-docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
-tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
-
[[package]]
name = "pytest"
version = "8.4.2"
@@ -2905,6 +2897,24 @@ pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
+ {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
+]
+
+[package.dependencies]
+pytest = ">=6.2.5"
+
+[package.extras]
+dev = ["pre-commit", "pytest-asyncio", "tox"]
+
[[package]]
name = "pytest-xprocess"
version = "0.22.2"
@@ -2954,14 +2964,14 @@ cli = ["click (>=5.0)"]
[[package]]
name = "python-multipart"
-version = "0.0.21"
+version = "0.0.22"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090"},
- {file = "python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92"},
+ {file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
+ {file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
]
[[package]]
@@ -3049,23 +3059,23 @@ files = [
[[package]]
name = "redis"
-version = "5.3.1"
+version = "6.4.0"
description = "Python client for Redis database and key-value store"
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97"},
- {file = "redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c"},
+ {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"},
+ {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"},
]
[package.dependencies]
async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
-PyJWT = ">=2.9.0"
[package.extras]
-hiredis = ["hiredis (>=3.0.0)"]
-ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"]
+hiredis = ["hiredis (>=3.2.0)"]
+jwt = ["pyjwt (>=2.9.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"]
[[package]]
name = "regex"
@@ -3232,14 +3242,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
-version = "14.2.0"
+version = "14.3.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["main", "dev"]
files = [
- {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
- {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
+ {file = "rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e"},
+ {file = "rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8"},
]
[package.dependencies]
@@ -3450,31 +3460,31 @@ oldlibyaml = ["ruamel.yaml.clib ; platform_python_implementation == \"CPython\""
[[package]]
name = "ruff"
-version = "0.14.13"
+version = "0.14.14"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
- {file = "ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b"},
- {file = "ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed"},
- {file = "ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841"},
- {file = "ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c"},
- {file = "ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b"},
- {file = "ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae"},
- {file = "ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e"},
- {file = "ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c"},
- {file = "ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680"},
- {file = "ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef"},
- {file = "ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247"},
- {file = "ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47"},
+ {file = "ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"},
+ {file = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"},
+ {file = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"},
+ {file = "ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"},
+ {file = "ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"},
+ {file = "ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"},
+ {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"},
]
[[package]]
@@ -3534,14 +3544,14 @@ typing-extensions = ">=4.7.1"
[[package]]
name = "sentry-sdk"
-version = "2.50.0"
+version = "2.51.0"
description = "Python client for Sentry (https://sentry.io)"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
- {file = "sentry_sdk-2.50.0-py2.py3-none-any.whl", hash = "sha256:0ef0ed7168657ceb5a0be081f4102d92042a125462d1d1a29277992e344e749e"},
- {file = "sentry_sdk-2.50.0.tar.gz", hash = "sha256:873437a989ee1b8b25579847bae8384515bf18cfed231b06c591b735c1781fe3"},
+ {file = "sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f"},
+ {file = "sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7"},
]
[package.dependencies]
@@ -3764,6 +3774,18 @@ files = [
doc = ["reno", "sphinx"]
test = ["pytest", "tornado (>=4.5)", "typeguard"]
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+groups = ["main"]
+files = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
[[package]]
name = "tomlkit"
version = "0.14.0"
@@ -3816,6 +3838,65 @@ rich = ">=10.11.0"
shellingham = ">=1.3.0"
typing-extensions = ">=3.7.4.3"
+[[package]]
+name = "types-cffi"
+version = "1.17.0.20250915"
+description = "Typing stubs for cffi"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c"},
+ {file = "types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06"},
+]
+
+[package.dependencies]
+types-setuptools = "*"
+
+[[package]]
+name = "types-pyopenssl"
+version = "24.1.0.20240722"
+description = "Typing stubs for pyOpenSSL"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39"},
+ {file = "types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54"},
+]
+
+[package.dependencies]
+cryptography = ">=35.0.0"
+types-cffi = "*"
+
+[[package]]
+name = "types-redis"
+version = "4.6.0.20241004"
+description = "Typing stubs for redis"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e"},
+ {file = "types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed"},
+]
+
+[package.dependencies]
+cryptography = ">=35.0.0"
+types-pyOpenSSL = "*"
+
+[[package]]
+name = "types-setuptools"
+version = "80.10.0.20260124"
+description = "Typing stubs for setuptools"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "types_setuptools-80.10.0.20260124-py3-none-any.whl", hash = "sha256:efed7e044f01adb9c2806c7a8e1b6aa3656b8e382379b53d5f26ee3db24d4c01"},
+ {file = "types_setuptools-80.10.0.20260124.tar.gz", hash = "sha256:1b86d9f0368858663276a0cbe5fe5a9722caf94b5acde8aba0399a6e90680f20"},
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -4139,14 +4220,14 @@ anyio = ">=3.0.0"
[[package]]
name = "wcwidth"
-version = "0.3.2"
+version = "0.5.0"
description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315"},
- {file = "wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad"},
+ {file = "wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695"},
+ {file = "wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333"},
]
[[package]]
@@ -4367,5 +4448,5 @@ propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
-python-versions = ">=3.11"
-content-hash = "43d20a8208cbb6e226ae91ece8c23e88124f15fe1891e1bf7adfe2b79874f959"
+python-versions = ">=3.11,<4"
+content-hash = "601f2679d45e7bd26580f86bfdeb6324ae6fa5d57ee7f80053fa2469815eaa0a"
diff --git a/pyproject.toml b/pyproject.toml
index 902e93f..436cc7b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,13 @@
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
[project]
name = "deribit-tracker"
-version = "0.3.0"
+version = "0.5.0"
description = "Deribit Price Tracker API"
readme = "README.md"
-requires-python = ">=3.11"
+requires-python = ">=3.11,<4"
license = "MIT"
authors = [
{name = "script-logic", email = "dev.scriptlogic@gmail.com"}
@@ -14,11 +18,14 @@ dependencies = [
"sqlalchemy (>=2.0.46,<3.0.0)",
"alembic (>=1.18.1,<2.0.0)",
"aiohttp (>=3.13.3,<4.0.0)",
- "celery[redis] (>=5.6.2,<6.0.0)",
"asyncpg (>=0.31.0,<0.32.0)",
"pydantic (>=2.12.5,<3.0.0)",
"pydantic-settings (>=2.12.0,<3.0.0)",
"psycopg2-binary (>=2.9.11,<3.0.0)",
+ "toml (>=0.10.2,<0.11.0)",
+ "celery[redis] (>=5.6.2,<6.0.0)",
+ "types-redis (>=4.6.0.20241004,<5.0.0.0)",
+ "jinja2 (>=3.1.6,<4.0.0)",
]
[tool.poetry]
@@ -36,17 +43,18 @@ bandit = "^1.9.3"
safety = "^3.7.0"
detect-secrets = "^1.5.0"
httpx = "^0.28.1"
-
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"
+celery-types = "^0.24.0"
+pytest-mock = "^3.15.1"
[tool.ruff]
target-version = "py311"
line-length = 79
-format.quote-style = "double"
lint.select = ["E", "W", "F", "I", "B", "C4", "UP", "RUF"]
exclude = ["__pycache__/", ".git/", "*.pyc"]
+format.indent-style = "space"
+format.skip-magic-trailing-comma = false
+format.quote-style = "double"
+preview = true
[tool.ruff.lint.isort]
known-first-party = ["app"]
@@ -72,7 +80,13 @@ addopts = [
"--cov=app",
"--cov-report=term-missing",
"--cov-report=xml",
- "--cov-report=html"
+ "--cov-report=html",
+ "--asyncio-mode=auto"
+]
+markers = [
+ "unit: unit tests",
+ "integration: integration tests",
+ "slow: slow running tests"
]
[tool.coverage.run]
@@ -80,7 +94,7 @@ source = ["app"]
omit = [
"*/__pycache__/*",
"*/tests/*",
- "*/migrations/*"
+ "*/migrations/*",
]
[tool.coverage.report]
@@ -92,5 +106,6 @@ exclude_lines = [
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
- "if __name__ == .__main__.:"
+ "if __name__ == .__main__.:",
+ "pass"
]
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..8a94f4c 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,3 @@
+"""
+Test package for Deribit Tracker.
+"""
diff --git a/tests/conftest.py b/tests/conftest.py
index 9f2179f..9885529 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,200 +1,36 @@
-"""
-Pytest configuration and shared fixtures for Deribit Tracker tests.
-
-Provides common test fixtures, configuration, and setup/teardown
-functions for the entire test suite.
-"""
-
-import asyncio
-from collections.abc import AsyncGenerator, Generator
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock
import pytest
-from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
-from app.core.config import Settings
-from app.main import app
-
-
-@pytest.fixture
-def client() -> Generator[TestClient, None, None]:
- """
- FastAPI TestClient fixture for HTTP endpoint testing.
-
- Yields:
- TestClient instance for making HTTP requests to the app.
- """
- with TestClient(app) as test_client:
- yield test_client
-
-
-@pytest.fixture
-def app_instance():
- """
- Raw FastAPI application instance.
-
- Returns:
- The FastAPI app instance for direct inspection or testing.
- """
- return app
-
-
-@pytest.fixture(autouse=True)
-def reset_settings():
- """
- Automatically reset Settings singleton before each test.
+from app.core import Settings
+from app.database import DatabaseManager
- Ensures clean state for settings-dependent tests.
- """
- original_instance = Settings._instance
- Settings._instance = None
- yield
-
- Settings._instance = original_instance
-
-
-@pytest.fixture
-def mock_settings():
- """
- Mock application settings for testing.
-
- Returns:
- Mock Settings instance with predefined values.
- """
- with patch("app.core.config.Settings.get_instance") as mock:
- mock_settings = Mock(spec=Settings)
- mock_settings.database.host = "test_host"
- mock_settings.database.port = 5432
- mock_settings.database.user = "test_user"
- mock_settings.database.password = Mock(
- get_secret_value=Mock(return_value="test_pass"),
- )
- mock_settings.database.db = "test_db"
- mock_settings.application.debug = False
- mock_settings.application.api_v1_prefix = "/api/v1"
- mock_settings.cors.origins = ["http://test.local"]
- mock.return_value = mock_settings
-
- yield mock_settings
+@pytest.fixture(scope="session")
+def mock_settings() -> Settings:
+ """Mock application settings."""
+ return Settings.init_instance()
@pytest.fixture
-def mock_async_session():
- """
- Mock async database session for testing.
-
- Returns:
- Mock AsyncSession with common methods mocked.
- """
- session = AsyncMock(spec=AsyncSession)
-
+def mock_db_session() -> MagicMock:
+ """Mock SQLAlchemy AsyncSession."""
+ session = MagicMock(spec=AsyncSession)
session.execute = AsyncMock()
+ session.add = MagicMock()
+ session.flush = AsyncMock()
+ session.refresh = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
session.close = AsyncMock()
- session.add = Mock()
- session.refresh = AsyncMock()
-
return session
@pytest.fixture
-def event_loop():
- """
- Create and manage asyncio event loop for async tests.
-
- Returns:
- AsyncIO event loop instance.
- """
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- yield loop
-
- loop.close()
-
-
-@pytest.fixture(autouse=True)
-def capture_logs(caplog):
- """
- Automatically capture logs for all tests.
-
- Args:
- caplog: Pytest's built-in caplog fixture.
-
- Returns:
- Configured caplog fixture.
- """
- caplog.set_level("DEBUG")
-
- return caplog
-
-
-@pytest.fixture
-def temp_env_file(tmp_path):
- """
- Create temporary .env file for environment variable testing.
-
- Args:
- tmp_path: Pytest temporary directory fixture.
-
- Returns:
- Path to temporary .env file.
- """
- env_file = tmp_path / ".env"
- env_content = """
-DATABASE__HOST=test_host
-DATABASE__PORT=5432
-DATABASE__USER=test_user
-DATABASE__PASSWORD=test_password
-DATABASE__DB=test_db
-APPLICATION__DEBUG=false
-CORS__ORIGINS=["http://test.local"]
-"""
- env_file.write_text(env_content)
-
- return env_file
-
-
-def pytest_configure(config):
- """Register custom markers for test categorization."""
- config.addinivalue_line(
- "markers",
- "integration: mark test as integration test (requires "
- "external services)",
- )
- config.addinivalue_line(
- "markers",
- "slow: mark test as slow-running",
- )
- config.addinivalue_line(
- "markers",
- "async_test: mark test as requiring async execution",
- )
-
-
-@pytest.fixture
-def anyio_backend():
- """Configure anyio backend for async tests."""
- return "asyncio"
-
-
-@pytest.fixture
-async def async_client() -> AsyncGenerator[TestClient, None]:
- """
- Async-compatible TestClient fixture.
-
- Yields:
- TestClient instance for async HTTP testing.
- """
- with TestClient(app) as test_client:
- yield test_client
-
-
-@pytest.fixture(autouse=True)
-def capture_all_output(capsys):
- """Capture all stdout/stderr output for tests."""
- yield
- capsys.readouterr()
+def mock_db_manager(mock_db_session: MagicMock) -> MagicMock:
+ """Mock DatabaseManager."""
+ manager = MagicMock(spec=DatabaseManager)
+ manager.get_session = MagicMock()
+ manager.get_session.return_value.__aenter__.return_value = mock_db_session
+ return manager
diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_api/test_endpoints.py b/tests/test_api/test_endpoints.py
new file mode 100644
index 0000000..669b666
--- /dev/null
+++ b/tests/test_api/test_endpoints.py
@@ -0,0 +1,118 @@
+from collections.abc import AsyncGenerator
+from datetime import UTC, datetime
+from typing import Any
+from unittest.mock import AsyncMock
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+from app.database.models import PriceTick
+from app.dependencies.database import get_db_session
+from app.dependencies.services import get_price_service
+from app.main import app
+from app.services import PriceService
+
+
+@pytest.mark.unit
+class TestAPIEndpoints:
+ @pytest.fixture
+ def mock_service(self) -> AsyncMock:
+ return AsyncMock(spec_set=PriceService)
+
+ @pytest.fixture
+ async def client(
+ self,
+ mock_service: AsyncMock,
+ ) -> AsyncGenerator[Any, None]:
+ """
+ Create test client with overridden dependencies.
+ """
+ app.dependency_overrides[get_price_service] = lambda: mock_service
+ app.dependency_overrides[get_db_session] = lambda: AsyncMock()
+
+ async with AsyncClient(
+ transport=ASGITransport(app=app),
+ base_url="http://test/api/v1",
+ ) as ac:
+ yield ac
+
+ app.dependency_overrides = {}
+
+ @pytest.mark.asyncio
+ async def test_get_latest_price_success(
+ self,
+ client: AsyncClient,
+ mock_service: AsyncMock,
+ ) -> None:
+ """Test getting latest price endpoint."""
+ expected_tick = PriceTick(
+ id=1,
+ ticker="btc_usd",
+ price=50000.0,
+ timestamp=1700000000,
+ created_at=datetime.now(UTC),
+ )
+ mock_service.get_latest_price.return_value = expected_tick
+
+ response = await client.get("/prices/latest?ticker=btc_usd")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["price"] == 50000.0
+ assert data["ticker"] == "btc_usd"
+ assert "created_at" in data
+
+ @pytest.mark.asyncio
+ async def test_get_latest_price_not_found(
+ self,
+ client: AsyncClient,
+ mock_service: AsyncMock,
+ ) -> None:
+ """Test 404 response when no price found."""
+ mock_service.get_latest_price.return_value = None
+
+ response = await client.get("/prices/latest?ticker=btc_usd")
+
+ assert response.status_code == 404
+ assert response.json()["error_type"] == "not_found"
+
+ @pytest.mark.asyncio
+ async def test_get_all_prices_pagination(
+ self,
+ client: AsyncClient,
+ mock_service: AsyncMock,
+ ) -> None:
+ """Test pagination parameters passing."""
+ mock_service.get_all_prices.return_value = []
+
+ await client.get("/prices/?ticker=btc_usd&limit=50&offset=10")
+
+ mock_service.get_all_prices.assert_awaited_once_with(
+ ticker="btc_usd",
+ limit=50,
+ offset=10,
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_stats_empty(
+ self,
+ client: AsyncClient,
+ mock_service: AsyncMock,
+ ) -> None:
+ """Test stats endpoint handles empty data correctly."""
+ mock_service.get_price_statistics.return_value = {
+ "ticker": "btc_usd",
+ "count": 0,
+ "min_price": None,
+ "max_price": None,
+ "avg_price": None,
+ "latest_price": None,
+ "latest_timestamp": None,
+ "price_at_time": None,
+ "closest_price": None,
+ }
+
+ response = await client.get("/prices/stats?ticker=btc_usd")
+
+ assert response.status_code == 404
+ assert response.json()["error_type"] == "not_found"
diff --git a/tests/test_clients/__init__.py b/tests/test_clients/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_clients/test_deribit.py b/tests/test_clients/test_deribit.py
new file mode 100644
index 0000000..cd59444
--- /dev/null
+++ b/tests/test_clients/test_deribit.py
@@ -0,0 +1,113 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import aiohttp
+import pytest
+
+from app.clients import DeribitClient
+from app.clients.exceptions import DeribitAPIError
+
+
+@pytest.mark.unit
+class TestDeribitClient:
+ @pytest.fixture
+ def client(self) -> DeribitClient:
+ return DeribitClient(base_url="https://test.deribit.com")
+
+ @pytest.mark.asyncio
+ @patch("aiohttp.ClientSession.request")
+ async def test_get_index_price_success(
+ self,
+ mock_request: MagicMock,
+ client: DeribitClient,
+ ) -> None:
+ """Test successful price retrieval."""
+ mock_response = AsyncMock()
+ mock_response.status = 200
+ mock_response.raise_for_status = MagicMock()
+ mock_response.json.return_value = {
+ "result": {"index_price": 50000.0},
+ }
+ mock_request.return_value.__aenter__.return_value = mock_response
+
+ with patch.object(
+ client,
+ "get_session",
+ new_callable=AsyncMock,
+ ) as mock_get_session:
+ mock_session = MagicMock()
+ mock_session.request = mock_request
+ mock_get_session.return_value = mock_session
+
+ price = await client.get_index_price("btc_usd")
+
+ assert price == 50000.0
+ mock_response.raise_for_status.assert_called_once()
+ mock_response.json.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_index_price_invalid_currency(
+ self,
+ client: DeribitClient,
+ ) -> None:
+ """Test invalid currency raises ValueError."""
+ with pytest.raises(ValueError):
+ await client.get_index_price("invalid")
+
+ @pytest.mark.asyncio
+ @patch("aiohttp.ClientSession.request")
+ async def test_api_server_error_retry(
+ self,
+ mock_request: MagicMock,
+ client: DeribitClient,
+ ) -> None:
+ """
+ Test that 500 errors trigger retries and raise specific exception.
+ """
+ req_info = MagicMock()
+ history = MagicMock()
+
+ mock_request.side_effect = aiohttp.ClientResponseError(
+ request_info=req_info,
+ history=history,
+ status=500,
+ message="Server Error",
+ )
+
+ with patch.object(
+ client,
+ "get_session",
+ new_callable=AsyncMock,
+ ) as mock_get_session:
+ mock_session = MagicMock()
+ mock_session.request = mock_request
+ mock_get_session.return_value = mock_session
+ alias_private = (
+ client._make_request # pyright: ignore[reportPrivateUsage]
+ )
+ with patch("asyncio.sleep", new_callable=AsyncMock):
+ with pytest.raises(DeribitAPIError) as exc:
+ await alias_private("GET", "/test", max_retries=2)
+
+ assert "HTTP error 500" in str(exc.value)
+
+ @pytest.mark.asyncio
+ @patch("app.clients.DeribitClient.get_index_price")
+ async def test_get_all_prices(
+ self,
+ mock_get_price: AsyncMock,
+ client: DeribitClient,
+ ) -> None:
+ """Test gathering multiple prices."""
+
+ async def side_effect(currency: str) -> float: # noqa: RUF029
+ if currency == "btc_usd":
+ return 50000.0
+ return 3000.0
+
+ mock_get_price.side_effect = side_effect
+
+ result = await client.get_all_prices()
+
+ assert result["btc_usd"] == 50000.0
+ assert result["eth_usd"] == 3000.0
+ assert len(result) == 2
diff --git a/tests/test_config.py b/tests/test_config.py
deleted file mode 100644
index dd2472e..0000000
--- a/tests/test_config.py
+++ /dev/null
@@ -1,415 +0,0 @@
-"""
-Unit tests for application configuration management.
-
-Tests the settings loading, validation, and singleton behavior
-of the configuration system.
-"""
-
-import os
-from unittest.mock import patch
-
-import pytest
-from pydantic import ValidationError
-
-from app.core.config import (
- ApplicationSettings,
- CORSSettings,
- DatabaseSettings,
- DeribitAPISettings,
- RedisSettings,
- Settings,
- get_settings,
-)
-
-
-class TestDatabaseSettings:
- """Test database configuration validation."""
-
- def test_default_values(self):
- """Test default values for database settings."""
- settings = DatabaseSettings(
- user="test",
- password="secret", # type: ignore
- db="test_db",
- )
-
- assert settings.host == "localhost"
- assert settings.port == 5432
- assert settings.user == "test"
- assert settings.db == "test_db"
-
- def test_port_validation(self):
- """Test port number validation."""
- settings = DatabaseSettings(
- user="test",
- password="secret", # type: ignore
- db="test_db",
- port=5432,
- )
- assert settings.port == 5432
-
- with pytest.raises(ValidationError):
- DatabaseSettings(
- user="test",
- password="secret", # type: ignore
- db="test_db",
- port=0,
- )
-
- with pytest.raises(ValidationError):
- DatabaseSettings(
- user="test",
- password="secret", # type: ignore
- db="test_db",
- port=65536,
- )
-
- def test_dsn_generation(self):
- """Test Data Source Name generation."""
- settings = DatabaseSettings(
- host="db.example.com",
- port=5432,
- user="test_user",
- password="test_pass", # type: ignore
- db="test_db",
- )
-
- assert settings.dsn == (
- "postgresql://test_user:test_pass@db.example.com:5432/test_db"
- )
- assert settings.async_dsn == (
- "postgresql+asyncpg://test_user"
- ":test_pass@db.example.com:5432/test_db"
- )
-
- def test_password_security(self):
- """Test password is stored as SecretStr."""
- settings = DatabaseSettings(
- user="test",
- password="super_secret", # type: ignore
- db="test_db",
- )
-
- assert isinstance(settings.password, type(settings.password))
- assert settings.password.get_secret_value() == "super_secret"
- assert "super_secret" not in str(settings)
- assert "super_secret" not in repr(settings)
-
-
-class TestDeribitAPISettings:
- """Test Deribit API configuration."""
-
- def test_default_values(self):
- """Test default values for Deribit API settings."""
- settings = DeribitAPISettings()
-
- assert settings.base_url == "https://www.deribit.com/api/v2"
- assert settings.client_id is None
- assert settings.client_secret is None
- assert not settings.is_configured
-
- def test_is_configured_property(self):
- """Test is_configured property logic."""
- settings = DeribitAPISettings()
- assert not settings.is_configured
-
- settings = DeribitAPISettings(client_id="test_id")
- assert not settings.is_configured
-
- settings = DeribitAPISettings(
- client_id="test_id",
- client_secret="test_secret", # type: ignore
- )
- assert settings.is_configured
-
- def test_secret_storage(self):
- """Test client secret security."""
- settings = DeribitAPISettings(
- client_id="test_id",
- client_secret="very_secret", # type: ignore
- )
-
- assert isinstance(settings.client_secret, type(settings.client_secret))
- assert settings.client_secret is not None
- assert settings.client_secret.get_secret_value() == "very_secret"
- assert "very_secret" not in str(settings)
-
-
-class TestRedisSettings:
- """Test Redis configuration."""
-
- def test_default_values(self):
- """Test default values for Redis settings."""
- settings = RedisSettings()
-
- assert settings.host == "localhost"
- assert settings.port == 6379
- assert settings.db == 0
-
- def test_url_generation(self):
- """Test Redis URL generation."""
- settings = RedisSettings(
- host="redis.example.com",
- port=6380,
- db=1,
- )
-
- assert settings.url == "redis://redis.example.com:6380/1"
-
- def test_db_validation(self):
- """Test Redis database number validation."""
- for db_num in [0, 1, 15]:
- settings = RedisSettings(db=db_num)
- assert settings.db == db_num
-
- with pytest.raises(ValidationError):
- RedisSettings(db=-1)
-
- with pytest.raises(ValidationError):
- RedisSettings(db=16)
-
-
-class TestApplicationSettings:
- """Test application core settings."""
-
- def test_default_values(self):
- """Test default values for application settings."""
- settings = ApplicationSettings()
-
- assert not settings.debug
- assert settings.api_v1_prefix == "/api/v1"
- assert settings.project_name == "Deribit Price Tracker API"
- assert settings.version == "0.3.0"
-
- def test_api_prefix_validation(self):
- """Test API prefix validation and normalization."""
- test_cases = [
- ("/api", "/api"),
- ("api", "/api"),
- ("/api/v1/", "/api/v1"),
- ("api/v2", "/api/v2"),
- ]
-
- for input_prefix, expected in test_cases:
- settings = ApplicationSettings(api_v1_prefix=input_prefix)
- assert settings.api_v1_prefix == expected
-
- with pytest.raises(ValidationError):
- ApplicationSettings(api_v1_prefix="")
-
-
-class TestCORSSettings:
- """Test CORS configuration."""
-
- def test_default_origins(self):
- """Test default CORS origins."""
- settings = CORSSettings()
-
- assert len(settings.origins) == 2
- assert "http://localhost:8000" in settings.origins
- assert "http://127.0.0.1:8000" in settings.origins
-
- def test_custom_origins(self):
- """Test custom CORS origins."""
- custom_origins = [
- "https://example.com",
- "https://api.example.com",
- ]
-
- settings = CORSSettings(origins=custom_origins)
- assert settings.origins == custom_origins
-
-
-class TestSettingsSingleton:
- """Test Settings singleton behavior."""
-
- def setup_method(self):
- """Reset singleton instance before each test."""
- Settings._instance = None
- for key in os.environ:
- if key.startswith(("DATABASE__", "DERIBIT_API__", "REDIS__")):
- del os.environ[key]
-
- def test_singleton_pattern(self):
- """Test that Settings is a proper singleton."""
- settings1 = get_settings(
- database={
- "host": "localhost",
- "port": 5432,
- "user": "test",
- "password": "test",
- "db": "test",
- },
- deribit_api={
- "client_id": None,
- "client_secret": None,
- },
- redis={
- "host": "localhost",
- "port": 6379,
- "db": 0,
- },
- celery={
- "worker_concurrency": 2,
- "beat_enabled": True,
- "task_track_started": True,
- },
- application={
- "debug": False,
- "api_v1_prefix": "/api/v1",
- "project_name": "Test",
- "version": "1.0",
- },
- cors={
- "origins": ["http://localhost:8000"],
- },
- )
-
- settings2 = get_settings()
-
- assert settings1 is settings2
- assert id(settings1) == id(settings2)
-
- def test_init_settings_function(self):
- """Test init_settings() convenience function."""
- settings = get_settings(
- database={
- "host": "testhost",
- "port": 5432,
- "user": "test",
- "password": "test",
- "db": "test",
- },
- deribit_api={
- "client_id": None,
- "client_secret": None,
- },
- redis={
- "host": "localhost",
- "port": 6379,
- "db": 0,
- },
- celery={
- "worker_concurrency": 2,
- "beat_enabled": True,
- "task_track_started": True,
- },
- application={
- "debug": False,
- "api_v1_prefix": "/api/v1",
- "project_name": "Test",
- "version": "1.0",
- },
- cors={
- "origins": ["http://localhost:8000"],
- },
- )
-
- assert get_settings() is settings
-
- @patch.dict(
- os.environ,
- {
- "DATABASE__HOST": "envhost",
- "DATABASE__PORT": "5432",
- "DATABASE__USER": "envuser",
- "DATABASE__PASSWORD": "envpass",
- "DATABASE__DB": "envdb",
- "APPLICATION__DEBUG": "true",
- },
- clear=True,
- )
- def test_environment_variable_loading(self):
- """Test loading settings from environment variables."""
- settings = get_settings()
-
- assert settings.database.host == "envhost"
- assert settings.database.port == 5432
- assert settings.database.user == "envuser"
- assert settings.database.db == "envdb"
- assert settings.application.debug is True
-
- def test_log_initialization(self, caplog: pytest.LogCaptureFixture):
- """Test logging during settings initialization."""
- with caplog.at_level("INFO"):
- get_settings(
- database={
- "host": "localhost",
- "port": 5432,
- "user": "test",
- "password": "test",
- "db": "test",
- },
- deribit_api={
- "client_id": None,
- "client_secret": None,
- },
- redis={
- "host": "localhost",
- "port": 6379,
- "db": 0,
- },
- celery={
- "worker_concurrency": 2,
- "beat_enabled": True,
- "task_track_started": True,
- },
- application={
- "debug": True,
- "api_v1_prefix": "/api/v1",
- "project_name": "Test",
- "version": "1.0",
- },
- cors={
- "origins": ["http://localhost:8000"],
- },
- )
- assert "Application settings initialized" in caplog.text
- assert "Debug logging enabled" in caplog.text
- assert "Deribit API credentials not configured" in caplog.text
-
-
-def test_settings_immutability():
- """Test that settings objects are immutable after creation."""
- settings = get_settings(
- database={
- "host": "localhost",
- "port": 5432,
- "user": "test",
- "password": "test",
- "db": "test",
- },
- deribit_api={
- "client_id": None,
- "client_secret": None,
- },
- redis={
- "host": "localhost",
- "port": 6379,
- "db": 0,
- },
- celery={
- "worker_concurrency": 2,
- "beat_enabled": True,
- "task_track_started": True,
- },
- application={
- "debug": False,
- "api_v1_prefix": "/api/v1",
- "project_name": "Test",
- "version": "1.0",
- },
- cors={
- "origins": ["http://localhost:8000"],
- },
- )
-
- assert settings.model_config.get("frozen") is True
- assert settings.database.model_config.get("frozen") is True
- import pydantic
-
- with pytest.raises(pydantic.ValidationError):
- settings.database.host = "newhost"
-
- original_host = settings.database.host
- assert settings.database.host == original_host
diff --git a/tests/test_database/__init__.py b/tests/test_database/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_database/test_repository.py b/tests/test_database/test_repository.py
new file mode 100644
index 0000000..25d3db9
--- /dev/null
+++ b/tests/test_database/test_repository.py
@@ -0,0 +1,104 @@
+import time
+from unittest.mock import MagicMock
+
+import pytest
+
+from app.database import PriceRepository
+from app.database.models import PriceTick
+
+
+@pytest.mark.unit
+class TestPriceRepository:
+ @pytest.fixture
+ def repository(self, mock_db_session: MagicMock) -> PriceRepository:
+ return PriceRepository(mock_db_session)
+
+ @pytest.mark.asyncio
+ async def test_create_price_tick(
+ self,
+ repository: PriceRepository,
+ mock_db_session: MagicMock,
+ ) -> None:
+ """Test creating a new price tick record."""
+ ticker = "btc_usd"
+ price = 50000.0
+ timestamp = int(time.time())
+
+ result = await repository.create(ticker, price, timestamp)
+
+ assert isinstance(result, PriceTick)
+ assert result.ticker == ticker
+ assert result.price == price
+ assert result.timestamp == timestamp
+ mock_db_session.add.assert_called_once()
+ mock_db_session.flush.assert_called_once()
+ mock_db_session.refresh.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_all_by_ticker(
+ self,
+ repository: PriceRepository,
+ mock_db_session: MagicMock,
+ ) -> None:
+ """Test retrieving all records for a ticker."""
+ mock_result = MagicMock()
+
+ expected_ticks = [
+ PriceTick(ticker="btc_usd", price=50000.0, id=1),
+ PriceTick(ticker="btc_usd", price=51000.0, id=2),
+ ]
+ mock_result.scalars.return_value.all.return_value = expected_ticks
+
+ mock_db_session.execute.return_value = mock_result
+
+ result = await repository.get_all_by_ticker("btc_usd", limit=10)
+
+ assert len(result) == 2
+ mock_db_session.execute.assert_called_once()
+ mock_result.scalars.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_latest_price(
+ self,
+ repository: PriceRepository,
+ mock_db_session: MagicMock,
+ ) -> None:
+ """Test retrieving the latest price."""
+ expected_tick = PriceTick(ticker="btc_usd", price=55000.0, id=1)
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = expected_tick
+
+ mock_db_session.execute.return_value = mock_result
+
+ result = await repository.get_latest_price("btc_usd")
+
+ assert result == expected_tick
+ mock_db_session.execute.assert_called_once()
+ mock_result.scalar_one_or_none.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_price_closest_to_timestamp(
+ self,
+ repository: PriceRepository,
+ mock_db_session: MagicMock,
+ ) -> None:
+ """Test retrieving price within time window."""
+ expected_tick = PriceTick(
+ ticker="btc_usd",
+ price=50000.0,
+ timestamp=1000,
+ )
+
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = expected_tick
+
+ mock_db_session.execute.return_value = mock_result
+
+ result = await repository.get_price_closest_to_timestamp(
+ "btc_usd",
+ 1005,
+ 60,
+ )
+
+ assert result == expected_tick
+ mock_db_session.execute.assert_called_once()
diff --git a/tests/test_initialization.py b/tests/test_initialization.py
deleted file mode 100644
index b49b6f4..0000000
--- a/tests/test_initialization.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""
-Tests for application initialization and startup.
-
-Verifies that the application starts correctly, dependencies
-are initialized properly, and error conditions are handled.
-"""
-
-import sys
-from unittest.mock import Mock, patch
-
-from app.core.config import Settings
-from app.core.logger import AppLogger
-
-
-class TestApplicationInitialization:
- """Test application startup and initialization."""
-
- def setup_method(self):
- """Reset application state before each test."""
- if "app" in sys.modules:
- del sys.modules["app"]
-
- Settings._instance = None
- AppLogger._initialized = False
-
- def test_module_level_logger_initialization(self):
- """Test logger is initialized at module level."""
- from app.core.logger import AppLogger
-
- AppLogger._initialized = False
-
- import importlib
- import sys
-
- old_core_module = sys.modules.pop("app.core", None)
-
- try:
- with patch("app.core.logger.get_logger") as mock_get_logger:
- mock_logger_instance = Mock()
- mock_get_logger.return_value = mock_logger_instance
-
- import app.core
-
- importlib.reload(app.core)
-
- assert hasattr(app.core, "logger")
- mock_get_logger.assert_called()
- finally:
- if old_core_module:
- sys.modules["app.core"] = old_core_module
-
- @patch("app.core.init_settings")
- def test_settings_initialization_error(
- self,
- mock_init_settings,
- caplog,
- ):
- """Test error handling when settings initialization fails."""
- # mock_init_settings.side_effect = ValueError("Invalid settings")
-
- # with patch("app.core.get_logger") as mock_get_logger:
- # mock_logger = Mock()
- # mock_logger.error = Mock()
- # mock_get_logger.return_value = mock_logger
-
- # import importlib
- # import app.core
-
- # importlib.reload(app.core)
-
- # call_args = mock_logger.error.call_args[0]
- # assert "Failed to initialize settings" in call_args[0]
- pass # TODO
-
- def test_metadata_loading_fallback(self):
- """Test fallback when package metadata cannot be loaded."""
- with patch("importlib.metadata.metadata") as mock_metadata:
- mock_metadata.side_effect = Exception("Metadata not available")
-
- import importlib
-
- import app
-
- importlib.reload(app)
-
- assert app.version == "Unknown version"
- assert app.title == "Untitled"
- assert app.description == "Unknown description"
-
- def test_metadata_loading_success(self):
- """Test successful package metadata loading."""
- mock_metadata = Mock()
- mock_metadata.json = {
- "version": "1.2.3",
- "name": "deribit-tracker",
- "summary": "Test description",
- }
-
- with patch("importlib.metadata.metadata", return_value=mock_metadata):
- import importlib
-
- import app
-
- importlib.reload(app)
-
- assert app.version == "1.2.3"
- assert app.title == "Deribit Tracker"
- assert app.description == "Test description"
-
- def test_module_exports(self):
- """Test that module exports expected symbols."""
- import app
-
- expected_exports = {
- "description",
- "title",
- "version",
- "core",
- "database",
- "clients",
- "api",
- "services",
- "tasks",
- }
-
- actual_exports = set(app.__all__)
- assert actual_exports == expected_exports
-
- for export in expected_exports:
- assert hasattr(app, export)
- assert getattr(app, export) is not None
-
- assert hasattr(app.core, "logger")
- assert hasattr(app.core, "settings")
-
- def test_settings_availability(self):
- """Test that settings are available after initialization."""
- import app
-
- assert hasattr(app.core, "settings")
- assert app.core.settings is not None
- assert isinstance(app.core.settings, Settings)
-
- def test_title_formatting(self):
- """Test that package name is formatted correctly."""
- mock_metadata = Mock()
- mock_metadata.json = {
- "version": "1.0.0",
- "name": "deribit-tracker",
- "summary": "Test",
- }
-
- with patch("importlib.metadata.metadata", return_value=mock_metadata):
- import importlib
-
- import app
-
- importlib.reload(app)
-
- assert app.title == "Deribit Tracker"
-
- def test_version_string_safety(self):
- """Test version is always a string."""
- mock_metadata = Mock()
- mock_metadata.json = {
- "version": 1.0,
- "name": "test",
- "summary": "Test",
- }
-
- with patch("importlib.metadata.metadata", return_value=mock_metadata):
- import importlib
-
- import app
-
- importlib.reload(app)
-
- assert isinstance(app.version, str)
- assert app.version == "1.0"
diff --git a/tests/test_logger.py b/tests/test_logger.py
deleted file mode 100644
index 61ea06a..0000000
--- a/tests/test_logger.py
+++ /dev/null
@@ -1,298 +0,0 @@
-"""
-Unit tests for application logging system.
-
-Tests logger configuration, log level management, and logging behavior
-across different scenarios.
-"""
-
-import logging
-from unittest.mock import Mock, patch
-
-from app.core.logger import AppLogger, get_logger
-
-
-class TestAppLogger:
- """Test AppLogger singleton and configuration."""
-
- def setup_method(self):
- """Reset logger state before each test."""
- AppLogger._initialized = False
- root_logger = logging.getLogger()
- for handler in root_logger.handlers[:]:
- root_logger.removeHandler(handler)
- handler.close()
- root_logger.setLevel(logging.WARNING)
-
- def test_singleton_initialization(self):
- """Test logger is initialized only once."""
- logger1 = AppLogger.get_logger("test.module1")
- logger2 = AppLogger.get_logger("test.module2")
-
- assert AppLogger._initialized is True
- assert logger1.name == "test.module1"
- assert logger2.name == "test.module2"
-
- root_logger = logging.getLogger()
- assert len(root_logger.handlers) == 1
-
- def test_logger_hierarchy(self):
- """Test logger hierarchy and propagation."""
- parent_logger = AppLogger.get_logger("parent")
- child_logger = AppLogger.get_logger("parent.child")
-
- assert child_logger.parent is parent_logger
- assert child_logger.propagate is True
-
- def test_get_logger_convenience_function(self):
- """Test get_logger() convenience function."""
- logger1 = get_logger("test.module")
- logger2 = AppLogger.get_logger("test.module")
-
- assert logger1 is logger2
- assert logger1.name == "test.module"
-
- def test_log_level_setting(self):
- """Test dynamic log level configuration."""
- test_logger = AppLogger.get_logger("test.level")
- test_logger.setLevel(logging.INFO)
-
- import io
-
- stream = io.StringIO()
- handler = logging.StreamHandler(stream)
- handler.setLevel(logging.DEBUG)
- formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
- handler.setFormatter(formatter)
- test_logger.addHandler(handler)
- test_logger.propagate = False
-
- test_logger.debug("This should not appear")
- test_logger.info("This should appear")
-
- log_output = stream.getvalue()
- assert "This should not appear" not in log_output
- assert "This should appear" in log_output
-
- test_logger.removeHandler(handler)
-
- def test_logger_specific_level_setting(self):
- """Test setting log level for specific logger only."""
- logger1 = AppLogger.get_logger("test.specific1")
- logger2 = AppLogger.get_logger("test.specific2")
-
- import io
-
- stream1 = io.StringIO()
- stream2 = io.StringIO()
-
- handler1 = logging.StreamHandler(stream1)
- handler2 = logging.StreamHandler(stream2)
-
- for handler in [handler1, handler2]:
- handler.setLevel(logging.DEBUG)
- handler.setFormatter(
- logging.Formatter("%(name)s - %(levelname)s - %(message)s")
- )
-
- logger1.addHandler(handler1)
- logger2.addHandler(handler2)
- logger1.propagate = False
- logger2.propagate = False
-
- logger1.setLevel(logging.ERROR)
- logger2.setLevel(logging.DEBUG)
-
- logger1.info("Logger1 info - should not appear")
- logger1.error("Logger1 error - should appear")
- logger2.debug("Logger2 debug - should appear")
- logger2.info("Logger2 info - should appear")
-
- output1 = stream1.getvalue()
- output2 = stream2.getvalue()
-
- assert "Logger1 info - should not appear" not in output1
- assert "Logger1 error - should appear" in output1
- assert "Logger2 debug - should appear" in output2
- assert "Logger2 info - should appear" in output2
-
- logger1.removeHandler(handler1)
- logger2.removeHandler(handler2)
-
- def test_disable_logger(self):
- """Test disabling specific loggers."""
- test_logger = AppLogger.get_logger("test.disabled")
- other_logger = AppLogger.get_logger("test.enabled")
-
- import io
-
- stream1 = io.StringIO()
- stream2 = io.StringIO()
-
- handler1 = logging.StreamHandler(stream1)
- handler2 = logging.StreamHandler(stream2)
-
- for handler in [handler1, handler2]:
- handler.setLevel(logging.INFO)
- handler.setFormatter(
- logging.Formatter("%(name)s - %(levelname)s - %(message)s")
- )
-
- test_logger.addHandler(handler1)
- other_logger.addHandler(handler2)
- test_logger.propagate = False
- other_logger.propagate = False
-
- AppLogger.disable_logger("test.disabled")
-
- test_logger.info("This should not appear")
- other_logger.info("This should appear")
-
- output1 = stream1.getvalue()
- output2 = stream2.getvalue()
-
- assert "This should not appear" not in output1
- assert "This should appear" in output2
-
- test_logger.removeHandler(handler1)
- other_logger.removeHandler(handler2)
-
- def test_log_format(self):
- """Test log message formatting."""
- test_logger = AppLogger.get_logger("test.format")
-
- import io
-
- stream = io.StringIO()
- handler = logging.StreamHandler(stream)
- handler.setLevel(logging.INFO)
- formatter = logging.Formatter(
- "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- )
- handler.setFormatter(formatter)
- test_logger.addHandler(handler)
- test_logger.propagate = False
-
- test_logger.info("Test message with number: %d", 42)
-
- log_output = stream.getvalue().strip()
-
- assert "test.format" in log_output
- assert "INFO" in log_output
- assert "Test message with number: 42" in log_output
- assert "20" in log_output
- assert "- test.format - INFO -" in log_output
-
- test_logger.removeHandler(handler)
-
- def test_multiple_get_logger_calls(self):
- """Test that multiple get_logger calls return same instance."""
- logger1 = AppLogger.get_logger("test.duplicate")
- logger2 = AppLogger.get_logger("test.duplicate")
- logger3 = get_logger("test.duplicate")
-
- assert logger1 is logger2
- assert logger1 is logger3
-
- def test_root_logger_configuration(self):
- """Test root logger is properly configured."""
- AppLogger.get_logger("test.root")
-
- root_logger = logging.getLogger()
-
- assert len(root_logger.handlers) == 1
-
- handler = root_logger.handlers[0]
- assert isinstance(handler, logging.StreamHandler)
- assert handler.level == logging.INFO
-
- def test_file_handler_not_added_by_default(self):
- """Test file handler is not added without debug mode."""
- AppLogger.get_logger("test.file")
-
- root_logger = logging.getLogger()
- file_handlers = [
- h
- for h in root_logger.handlers
- if isinstance(
- h,
- logging.handlers.RotatingFileHandler, # type: ignore
- )
- ]
-
- assert len(file_handlers) == 0
-
- def test_exception_logging(self):
- """Test logging of exceptions with traceback."""
- test_logger = AppLogger.get_logger("test.exception")
-
- import io
-
- stream = io.StringIO()
- handler = logging.StreamHandler(stream)
- handler.setLevel(logging.ERROR)
- formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s")
- handler.setFormatter(formatter)
- test_logger.addHandler(handler)
- test_logger.propagate = False
-
- try:
- raise ValueError("Test exception")
- except ValueError:
- test_logger.exception("An error occurred")
-
- log_output = stream.getvalue()
- assert "An error occurred" in log_output
- assert "ValueError" in log_output
- assert "Test exception" in log_output
-
- test_logger.removeHandler(handler)
-
- def test_log_level_string_conversion(self):
- """Test string to log level conversion."""
- for level_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
- AppLogger.set_level(level_name)
- root_logger = logging.getLogger()
- expected_level = getattr(logging, level_name)
- assert root_logger.level == expected_level
-
- AppLogger.set_level("INVALID_LEVEL")
- root_logger = logging.getLogger()
- assert root_logger.level == logging.INFO
-
-
-def test_logger_in_different_modules():
- """Test that loggers in different modules work correctly."""
- logger1 = get_logger("module1")
- logger2 = get_logger("module2.submodule")
- logger3 = get_logger("module3")
-
- assert logger1.name == "module1"
- assert logger2.name == "module2.submodule"
- assert logger3.name == "module3"
-
- root_logger = logging.getLogger()
- assert len(root_logger.handlers) == 1
-
-
-@patch("app.core.logger.Path.mkdir")
-@patch("logging.getLogger")
-def test_file_handler_creation_error(mock_get_logger, mock_mkdir):
- """Test error handling when file handler creation fails."""
- mock_mkdir.side_effect = PermissionError("Permission denied")
-
- mock_logger = Mock(spec=logging.Logger)
- mock_logger.warning = Mock()
- mock_logger.addHandler = Mock()
- mock_get_logger.return_value = mock_logger
-
- formatter = logging.Formatter()
-
- AppLogger._add_file_handler(mock_logger, formatter)
-
- mock_logger.warning.assert_called_once_with(
- "Could not create file handler: %s",
- mock_mkdir.side_effect,
- )
-
- mock_logger.addHandler.assert_not_called()
diff --git a/tests/test_main.py b/tests/test_main.py
deleted file mode 100644
index 5e67ad3..0000000
--- a/tests/test_main.py
+++ /dev/null
@@ -1,74 +0,0 @@
-import os
-
-import pytest
-from fastapi.testclient import TestClient
-
-from app.main import app
-
-client = TestClient(app)
-
-
-def test_root_endpoint():
- response = client.get("/")
- assert response.status_code == 200
- assert response.json() == {"message": "root"}
-
-
-def test_health_check():
- response = client.get("/health")
- assert response.status_code == 200
- assert response.json() == {"status": "healthy"}
-
-
-def test_api_metadata():
- response = client.get("/openapi.json")
- assert response.status_code == 200
-
- data = response.json()
- assert "info" in data
- assert "Deribit Tracker" in data["info"]["title"]
- assert "0.3.0" in data["info"]["version"]
-
-
-def test_cors_headers():
- """Test CORS functionality - both preflight and actual requests work."""
-
- if os.getenv("CI") or os.getenv("GITHUB_ACTIONS"):
- pytest.skip("Skipping detailed CORS test on CI")
-
- response_options = client.options(
- "/",
- headers={
- "Origin": "http://127.0.0.1:8000",
- "Access-Control-Request-Method": "GET",
- },
- )
-
- assert response_options.status_code == 200
- assert "access-control-allow-origin" in response_options.headers
- assert (
- response_options.headers["access-control-allow-origin"]
- == "http://127.0.0.1:8000"
- )
- assert "access-control-allow-methods" in response_options.headers
- assert "GET" in response_options.headers["access-control-allow-methods"]
-
- response_get = client.get(
- "/",
- headers={"Origin": "http://127.0.0.1:8000"},
- )
-
- assert response_get.status_code == 200
-
- response_bad_origin = client.get(
- "/",
- headers={"Origin": "http://evil.com"},
- )
-
- assert response_bad_origin.status_code == 200
-
-
-def test_nonexistent_endpoint():
- response = client.get("/nonexistent")
- assert response.status_code == 404
- assert "detail" in response.json()
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
deleted file mode 100644
index 8cc5455..0000000
--- a/tests/test_metadata.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from importlib.metadata import metadata
-
-from app import description, title, version
-
-
-def test_package_metadata_loaded():
- assert version is not None
- assert title is not None
- assert description is not None
- assert len(version) > 0
- assert len(title) > 0
-
-
-def test_version_matches_pyproject():
- pkg_metadata = metadata("deribit-tracker").json
- expected_version = pkg_metadata.get("version", "0.3.0")
- assert version == expected_version
-
-
-def test_title_formatting():
- assert "-" not in title
- assert title[0].isupper()
diff --git a/tests/test_services/__init__.py b/tests/test_services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_services/test_price_service.py b/tests/test_services/test_price_service.py
new file mode 100644
index 0000000..5162f07
--- /dev/null
+++ b/tests/test_services/test_price_service.py
@@ -0,0 +1,93 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from app.database import PriceRepository
+from app.database.models import PriceTick
+from app.services import PriceService
+
+
+@pytest.mark.unit
+class TestPriceService:
+ @pytest.fixture
+ def mock_repo(self) -> MagicMock:
+ return MagicMock(spec=PriceRepository)
+
+ @pytest.fixture
+ def service(self, mock_repo: MagicMock) -> PriceService:
+ return PriceService(mock_repo)
+
+ @pytest.mark.asyncio
+ async def test_get_latest_price_success(
+ self,
+ service: PriceService,
+ mock_repo: MagicMock,
+ ) -> None:
+ """Test successful retrieval of latest price."""
+ expected_tick = PriceTick(ticker="btc_usd", price=100.0)
+ mock_repo.get_latest_price = AsyncMock(return_value=expected_tick)
+
+ result = await service.get_latest_price("btc_usd")
+
+ assert result == expected_tick
+ mock_repo.get_latest_price.assert_called_once_with("btc_usd")
+
+ @pytest.mark.asyncio
+ async def test_validate_ticker_invalid(
+ self,
+ service: PriceService,
+ ) -> None:
+ """Test validation raises error for unsupported ticker."""
+ with pytest.raises(ValueError, match="Unsupported ticker"):
+ await service.get_latest_price("invalid_pair")
+
+ @pytest.mark.asyncio
+ async def test_create_price_tick_negative_price(
+ self,
+ service: PriceService,
+ ) -> None:
+ """Test validation raises error for negative price."""
+ with pytest.raises(ValueError, match="Price cannot be negative"):
+ await service.create_price_tick("btc_usd", -500.0)
+
+ @pytest.mark.asyncio
+ async def test_get_price_statistics_empty(
+ self,
+ service: PriceService,
+ mock_repo: MagicMock,
+ ) -> None:
+ """Test statistics calculation when no data exists."""
+ mock_repo.get_latest_price = AsyncMock(return_value=None)
+ mock_repo.get_all_by_ticker = AsyncMock(return_value=[])
+ mock_repo.get_price_at_timestamp = AsyncMock(return_value=None)
+ mock_repo.get_price_closest_to_timestamp = AsyncMock(return_value=None)
+
+ stats = await service.get_price_statistics("btc_usd")
+
+ assert stats["count"] == 0
+ assert stats["min_price"] is None
+ assert stats["avg_price"] is None
+
+ @pytest.mark.asyncio
+ async def test_get_price_statistics_calculated(
+ self,
+ service: PriceService,
+ mock_repo: MagicMock,
+ ) -> None:
+ """Test statistics calculation with data."""
+ ticks = [
+ PriceTick(price=100.0),
+ PriceTick(price=200.0),
+ PriceTick(price=300.0),
+ ]
+ mock_repo.get_latest_price = AsyncMock(return_value=ticks[-1])
+ mock_repo.get_all_by_ticker = AsyncMock(return_value=ticks)
+ mock_repo.get_price_at_timestamp = AsyncMock(return_value=None)
+ mock_repo.get_price_closest_to_timestamp = AsyncMock(return_value=None)
+
+ stats = await service.get_price_statistics("btc_usd")
+
+ assert stats["count"] == 3
+ assert stats["min_price"] == 100.0
+ assert stats["max_price"] == 300.0
+ assert stats["avg_price"] == 200.0
diff --git a/tests/test_tasks/__init__.py b/tests/test_tasks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_tasks/test_celery.py b/tests/test_tasks/test_celery.py
new file mode 100644
index 0000000..7f6a4d1
--- /dev/null
+++ b/tests/test_tasks/test_celery.py
@@ -0,0 +1,47 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from app.tasks.price_collection import collect_single_price
+
+
+@pytest.mark.unit
+class TestCeleryTasks:
+ @pytest.mark.asyncio
+ @patch("app.tasks.price_collection.get_deribit_client_tasks")
+ @patch("app.tasks.price_collection.get_database_manager_tasks")
+ async def test_collect_single_price_success(
+ self,
+ mock_get_db_manager: MagicMock,
+ mock_get_client: MagicMock,
+ ) -> None:
+ """Test successful execution of collection task."""
+ mock_client = AsyncMock()
+ mock_client.get_index_price.return_value = 50000.0
+ mock_get_client.return_value = mock_client
+
+ mock_db_session = AsyncMock()
+ mock_db_manager = MagicMock()
+ mock_db_manager.get_session.return_value.__aenter__.return_value = (
+ mock_db_session
+ )
+ mock_get_db_manager.return_value = mock_db_manager
+
+ with patch("app.tasks.price_collection.PriceRepository") as MockRepo:
+ mock_repo_instance = MagicMock()
+ mock_repo_instance.create = AsyncMock()
+ mock_repo_instance.create.return_value.id = 123
+ MockRepo.return_value = mock_repo_instance
+
+ result = await collect_single_price("btc_usd")
+
+ assert result is not None
+ assert result["success"] is True
+ assert result["price"] == 50000.0
+ assert result["record_id"] == 123
+
+ @pytest.mark.asyncio
+ async def test_collect_single_price_invalid_ticker(self) -> None:
+ """Test task behavior with unsupported ticker."""
+ result = await collect_single_price("invalid")
+ assert result is None