From e17c7e3c4bcd61bec00b7659b3d2665ec503907e Mon Sep 17 00:00:00 2001 From: script-logic Date: Tue, 27 Jan 2026 17:15:23 +0300 Subject: [PATCH 1/9] feat: api routes --- .secrets.baseline | 2 +- app/api/__init__.py | 7 + app/api/exceptions.py | 162 ++++++++++++ app/api/v1/__init__.py | 16 ++ app/api/v1/endpoints/prices.py | 439 +++++++++++++++++++++++++++++++++ app/api/v1/schemas.py | 144 +++++++++++ app/database/repository.py | 4 +- app/main.py | 19 +- app/services/__init__.py | 7 + app/services/price_service.py | 303 +++++++++++++++++++++++ tests/test_main.py | 8 +- 11 files changed, 1104 insertions(+), 7 deletions(-) create mode 100644 app/api/exceptions.py diff --git a/.secrets.baseline b/.secrets.baseline index 2df9f64..669d54c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -225,5 +225,5 @@ } ] }, - "generated_at": "2026-01-26T20:19:50Z" + "generated_at": "2026-01-27T13:38:20Z" } diff --git a/app/api/__init__.py b/app/api/__init__.py index e69de29..a857ff2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -0,0 +1,7 @@ +""" +API module package exports. +""" + +from .v1 import api_router + +__all__ = ["api_router"] diff --git a/app/api/exceptions.py b/app/api/exceptions.py new file mode 100644 index 0000000..21935bc --- /dev/null +++ b/app/api/exceptions.py @@ -0,0 +1,162 @@ +""" +Custom exceptions and error handlers for API error handling. +""" + +from typing import Any, cast + +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( + request: Request, + exc: Exception, +) -> JSONResponse: + """Handle HTTPException and APIError.""" + if not isinstance(exc, HTTPException): + raise exc + + 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": cast(str, 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": str(exc.detail), + "error_type": "http_error", + } + + return JSONResponse( + status_code=exc.status_code, + content=response_content, + ) + + +async def validation_exception_handler( + 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( + 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/v1/__init__.py b/app/api/v1/__init__.py index e69de29..fffbf5c 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -0,0 +1,16 @@ +""" +API version 1 router configuration. +""" + +from fastapi import APIRouter + +from .endpoints import prices + +api_router = APIRouter() +api_router.include_router( + prices.router, + prefix="/prices", + tags=["prices"], +) + +__all__ = ["api_router"] diff --git a/app/api/v1/endpoints/prices.py b/app/api/v1/endpoints/prices.py index e69de29..7dae9d2 100644 --- a/app/api/v1/endpoints/prices.py +++ b/app/api/v1/endpoints/prices.py @@ -0,0 +1,439 @@ +""" +FastAPI endpoints for cryptocurrency price data. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +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.database.deps import get_db_session +from app.database.repository import PriceRepository +from app.services.price_service import PriceService + +logger = get_logger(__name__) + +router = APIRouter() + + +def get_price_service( + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> PriceService: + """Dependency for PriceService.""" + repository = PriceRepository(session) + return PriceService(repository) + + +@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: Annotated[PriceService, Depends(get_price_service)], +) -> 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: Annotated[PriceService, Depends(get_price_service)], +) -> 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: Annotated[PriceService, Depends(get_price_service)], +) -> 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: Annotated[PriceService, Depends(get_price_service)], + 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: Annotated[PriceService, Depends(get_price_service)], +) -> 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: Annotated[PriceService, Depends(get_price_service)], + 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/database/repository.py b/app/database/repository.py index 491d5d1..6c7ec1e 100644 --- a/app/database/repository.py +++ b/app/database/repository.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager 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 @@ -169,7 +169,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/main.py b/app/main.py index accc8a7..4e1912e 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,8 @@ from fastapi.middleware.cors import CORSMiddleware from . import description, title, version +from .api import api_router +from .api.exceptions import register_exception_handlers from .core import get_logger, settings logger = get_logger(__name__) @@ -22,7 +24,13 @@ def create_app() -> FastAPI: version=version, description=description, lifespan=lifespan, + openapi_url=f"{settings.application.api_v1_prefix}/openapi.json", + docs_url="/docs", + redoc_url="/redoc", ) + + register_exception_handlers(app) + app.add_middleware( CORSMiddleware, allow_origins=settings.cors.origins, @@ -30,18 +38,25 @@ def create_app() -> FastAPI: allow_methods=["GET", "OPTIONS"], allow_headers=["*"], ) + + app.include_router( + api_router, + prefix=settings.application.api_v1_prefix, + ) + return app try: app: FastAPI = create_app() except Exception as e: - logger.info("FastAPI app creation error", e) + logger.error("FastAPI app creation error: %s", e) + raise @app.get("/") async def root(): - return {"message": "root"} + return {"message": "Deribit Price Tracker API is running"} @app.get("/health") 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..7ffde74 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}. " + "Supported: 'btc_usd', 'eth_usd'" + ) diff --git a/tests/test_main.py b/tests/test_main.py index 5e67ad3..efb4eb0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -11,7 +11,9 @@ def test_root_endpoint(): response = client.get("/") assert response.status_code == 200 - assert response.json() == {"message": "root"} + assert response.json() == { + "message": "Deribit Price Tracker API is running", + } def test_health_check(): @@ -21,7 +23,9 @@ def test_health_check(): def test_api_metadata(): - response = client.get("/openapi.json") + from app.core import settings + + response = client.get(f"{settings.application.api_v1_prefix}/openapi.json") assert response.status_code == 200 data = response.json() From 3f7e4afd4ca3b27595bbbb5cd92366fa1e4a4de6 Mon Sep 17 00:00:00 2001 From: script-logic Date: Tue, 27 Jan 2026 19:39:39 +0300 Subject: [PATCH 2/9] fix: formatting --- app/services/price_service.py | 6 +++--- pyproject.toml | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/services/price_service.py b/app/services/price_service.py index 7ffde74..18f9603 100644 --- a/app/services/price_service.py +++ b/app/services/price_service.py @@ -213,11 +213,11 @@ async def get_price_statistics( if price_at_time_tick: price_at_time = price_at_time_tick.price - (closest_price_tick,) = ( + 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 @@ -299,5 +299,5 @@ def _validate_ticker(ticker: str) -> None: if normalized_ticker not in {"btc_usd", "eth_usd"}: raise ValueError( f"Unsupported ticker: {ticker}. " - "Supported: 'btc_usd', 'eth_usd'" + f"Supported: 'btc_usd', 'eth_usd'" ) diff --git a/pyproject.toml b/pyproject.toml index 902e93f..dc73f6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,9 +44,12 @@ build-backend = "poetry.core.masonry.api" [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"] From 9cdab4ee4b1346e39d9cd16a3925d6862fafcf41 Mon Sep 17 00:00:00 2001 From: script-logic Date: Tue, 27 Jan 2026 23:08:17 +0300 Subject: [PATCH 3/9] fix: styles --- .env.example | 2 +- .secrets.baseline | 20 ++++++------ app/__init__.py | 41 +++-------------------- app/api/__init__.py | 8 +++-- app/api/routes.py | 28 ++++++++++++++++ app/api/v1/__init__.py | 19 +++-------- app/api/v1/dependencies.py | 19 +++++++++++ app/api/v1/endpoints/__init__.py | 5 +++ app/api/v1/endpoints/prices.py | 12 +------ app/clients/deribit.py | 13 ++------ app/clients/exceptions.py | 7 ++++ app/core/__init__.py | 13 ++------ app/core/config.py | 10 +++++- app/core/logger.py | 31 ------------------ app/main.py | 35 +++++--------------- app/metadata.py | 45 +++++++++++++++++++++++++ poetry.lock | 14 +++++++- pyproject.toml | 11 ++++--- tests/test_config.py | 5 +-- tests/test_initialization.py | 56 ++++++-------------------------- tests/test_logger.py | 24 -------------- tests/test_main.py | 2 +- tests/test_metadata.py | 22 +++++-------- 23 files changed, 195 insertions(+), 247 deletions(-) create mode 100644 app/api/routes.py create mode 100644 app/api/v1/dependencies.py create mode 100644 app/clients/exceptions.py create mode 100644 app/metadata.py diff --git a/.env.example b/.env.example index 369dc5c..41c23d6 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ 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 +APPLICATION__VERSION=0.4.0 CORS__ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"] diff --git a/.secrets.baseline b/.secrets.baseline index 669d54c..86c5e1c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -163,7 +163,7 @@ "filename": "app\\core\\config.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 43 + "line_number": 44 } ], "tests\\test_config.py": [ @@ -172,58 +172,58 @@ "filename": "tests\\test_config.py", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 32 + "line_number": 33 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e", "is_verified": false, - "line_number": 73 + "line_number": 74 }, { "type": "Basic Auth Credentials", "filename": "tests\\test_config.py", "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e", "is_verified": false, - "line_number": 78 + "line_number": 79 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "1adfce9fa4bc6b1cbdf95ac2dc6180175da7558b", "is_verified": false, - "line_number": 89 + "line_number": 90 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0", "is_verified": false, - "line_number": 121 + "line_number": 122 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "ee27c133da056b1013f88c712f92460bc7b3c90a", "is_verified": false, - "line_number": 129 + "line_number": 130 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 240 + "line_number": 241 }, { "type": "Secret Keyword", "filename": "tests\\test_config.py", "hashed_secret": "fca268ae2442d5cabc3e12d87b349adf8bf7d76c", "is_verified": false, - "line_number": 316 + "line_number": 317 } ] }, - "generated_at": "2026-01-27T13:38:20Z" + "generated_at": "2026-01-27T20:07:53Z" } diff --git a/app/__init__.py b/app/__init__.py index 544007b..3892c3d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,49 +4,16 @@ 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() - +from . import api, clients, core, database, metadata, services, tasks +from .metadata import project_metadata __all__ = [ "api", "clients", "core", "database", - "description", + "metadata", + "project_metadata", "services", "tasks", - "title", - "version", ] diff --git a/app/api/__init__.py b/app/api/__init__.py index a857ff2..ba8a363 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -2,6 +2,10 @@ API module package exports. """ -from .v1 import api_router +from .routes import api_v1_router, health_check_router, root_router -__all__ = ["api_router"] +__all__ = [ + "api_v1_router", + "health_check_router", + "root_router", +] diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..feb8e40 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,28 @@ +""" +API version 1 router configuration. +""" + +from fastapi import APIRouter + +from .v1.endpoints import prices_router + +root_router = APIRouter() +health_check_router = APIRouter() + + +@root_router.get("/") +async def root(): + return {"message": "Deribit Price Tracker API is running"} + + +@health_check_router.get("/health") +async def health_check(): + return {"status": "healthy"} + + +api_v1_router = APIRouter() +api_v1_router.include_router( + prices_router, + prefix="/prices", + tags=["prices"], +) diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index fffbf5c..991c6e1 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -1,16 +1,5 @@ -""" -API version 1 router configuration. -""" +from .dependencies import get_price_service -from fastapi import APIRouter - -from .endpoints import prices - -api_router = APIRouter() -api_router.include_router( - prices.router, - prefix="/prices", - tags=["prices"], -) - -__all__ = ["api_router"] +__all__ = [ + "get_price_service", +] diff --git a/app/api/v1/dependencies.py b/app/api/v1/dependencies.py new file mode 100644 index 0000000..1566c54 --- /dev/null +++ b/app/api/v1/dependencies.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import get_logger +from app.database.deps import get_db_session +from app.database.repository import PriceRepository +from app.services.price_service import PriceService + +logger = get_logger(__name__) + + +def get_price_service( + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> PriceService: + """Dependency for PriceService.""" + repository = PriceRepository(session) + return PriceService(repository) 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 7dae9d2..0730316 100644 --- a/app/api/v1/endpoints/prices.py +++ b/app/api/v1/endpoints/prices.py @@ -5,9 +5,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy.ext.asyncio import AsyncSession from app.api.exceptions import NotFoundError +from app.api.v1.dependencies import get_price_service from app.api.v1.schemas import ( DateFilterParams, ErrorResponse, @@ -16,8 +16,6 @@ PriceTickResponse, ) from app.core import get_logger -from app.database.deps import get_db_session -from app.database.repository import PriceRepository from app.services.price_service import PriceService logger = get_logger(__name__) @@ -25,14 +23,6 @@ router = APIRouter() -def get_price_service( - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> PriceService: - """Dependency for PriceService.""" - repository = PriceRepository(session) - return PriceService(repository) - - @router.get( "/", response_model=list[PriceTickResponse], diff --git a/app/clients/deribit.py b/app/clients/deribit.py index 17d8d1c..42fe6f6 100644 --- a/app/clients/deribit.py +++ b/app/clients/deribit.py @@ -13,16 +13,9 @@ from app.core import get_logger, settings -logger = get_logger(__name__) - +from .exceptions import DeribitAPIError -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) +logger = get_logger(__name__) class DeribitClient: @@ -148,7 +141,7 @@ async def _make_request( wait_time = 2**attempt logger.warning( "Connection error: %s, retrying in %s seconds...", - str(error), + error, wait_time, ) await asyncio.sleep(wait_time) 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..786fd79 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -6,19 +6,10 @@ centralized logging. """ -from .config import get_settings +from .config import 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__ = [ "get_logger", - "get_settings", + "settings", ] diff --git a/app/core/config.py b/app/core/config.py index 69d17fd..2d323c8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -5,6 +5,7 @@ environment variable loading, and singleton pattern for global access. """ +import logging from typing import Any, ClassVar from pydantic import BaseModel, Field, SecretStr, field_validator @@ -139,7 +140,7 @@ class ApplicationSettings(BaseModel): debug: bool = False api_v1_prefix: str = "/api/v1" project_name: str = "Deribit Price Tracker API" - version: str = "0.3.0" + version: str = "0.4.0" model_config = {"frozen": True} @@ -279,3 +280,10 @@ def get_settings(**kwargs) -> Settings: return Settings.init_instance(**kwargs) return Settings._instance + + +try: + settings = get_settings() +except Exception as e: + logging.error("Failed to initialize settings: %s", e) + raise 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/main.py b/app/main.py index 4e1912e..3e3d0b9 100644 --- a/app/main.py +++ b/app/main.py @@ -1,32 +1,23 @@ -from contextlib import asynccontextmanager - from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from . import description, title, version -from .api import api_router +from . import project_metadata +from .api import api_v1_router, health_check_router, root_router from .api.exceptions import register_exception_handlers from .core import get_logger, settings logger = get_logger(__name__) -@asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("Starting Deribit Price Tracker API...") - yield - logger.info("Shutting down Deribit Price Tracker API...") - - def create_app() -> FastAPI: app = FastAPI( - title=title, - version=version, - description=description, - lifespan=lifespan, + title=project_metadata["title"], + version=project_metadata["version"], + description=project_metadata["description"], openapi_url=f"{settings.application.api_v1_prefix}/openapi.json", docs_url="/docs", redoc_url="/redoc", + debug=settings.application.debug, ) register_exception_handlers(app) @@ -40,9 +31,11 @@ def create_app() -> FastAPI: ) app.include_router( - api_router, + api_v1_router, prefix=settings.application.api_v1_prefix, ) + app.include_router(root_router) + app.include_router(health_check_router) return app @@ -52,13 +45,3 @@ def create_app() -> FastAPI: except Exception as e: logger.error("FastAPI app creation error: %s", e) raise - - -@app.get("/") -async def root(): - return {"message": "Deribit Price Tracker API is running"} - - -@app.get("/health") -async def health_check(): - return {"status": "healthy"} diff --git a/app/metadata.py b/app/metadata.py new file mode 100644 index 0000000..78713a9 --- /dev/null +++ b/app/metadata.py @@ -0,0 +1,45 @@ +from pathlib import Path + +import toml + +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +def get_project_metadata() -> 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 application title (e.g., "Deribit Tracker") + """ + pyproject_path = Path(__file__).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: + logger.warning(f"Warning: Could not read pyproject.toml: {e}") + + return { + "title": "Unknown", + "version": "Unknown", + "description": "", + } + + +project_metadata: dict[str, str] = get_project_metadata() diff --git a/poetry.lock b/poetry.lock index 6bdba5a..b70621f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3764,6 +3764,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" @@ -4368,4 +4380,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "43d20a8208cbb6e226ae91ece8c23e88124f15fe1891e1bf7adfe2b79874f959" +content-hash = "d26030b3deb1134b4c01aa5eacb1ef0fb07d0ead4d895e263b89262e031983a1" diff --git a/pyproject.toml b/pyproject.toml index dc73f6c..7695742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,10 @@ +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + [project] name = "deribit-tracker" -version = "0.3.0" +version = "0.4.0" description = "Deribit Price Tracker API" readme = "README.md" requires-python = ">=3.11" @@ -19,6 +23,7 @@ dependencies = [ "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)", ] [tool.poetry] @@ -37,10 +42,6 @@ safety = "^3.7.0" detect-secrets = "^1.5.0" httpx = "^0.28.1" -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" - [tool.ruff] target-version = "py311" line-length = 79 diff --git a/tests/test_config.py b/tests/test_config.py index dd2472e..e93d27c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ """ import os +from copy import copy from unittest.mock import patch import pytest @@ -179,7 +180,7 @@ def test_default_values(self): 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" + assert settings.version == "0.4.0" def test_api_prefix_validation(self): """Test API prefix validation and normalization.""" @@ -226,7 +227,7 @@ class TestSettingsSingleton: def setup_method(self): """Reset singleton instance before each test.""" Settings._instance = None - for key in os.environ: + for key in copy(os.environ): if key.startswith(("DATABASE__", "DERIBIT_API__", "REDIS__")): del os.environ[key] diff --git a/tests/test_initialization.py b/tests/test_initialization.py index b49b6f4..6c0dfc8 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -39,11 +39,11 @@ def test_module_level_logger_initialization(self): mock_logger_instance = Mock() mock_get_logger.return_value = mock_logger_instance - import app.core + import app.metadata - importlib.reload(app.core) + importlib.reload(app.metadata) - assert hasattr(app.core, "logger") + assert hasattr(app.metadata, "logger") mock_get_logger.assert_called() finally: if old_core_module: @@ -72,55 +72,19 @@ def test_settings_initialization_error( # 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", + "api", + "clients", "core", "database", - "clients", - "api", + "project_metadata", "services", "tasks", + "metadata", } actual_exports = set(app.__all__) @@ -157,7 +121,7 @@ def test_title_formatting(self): importlib.reload(app) - assert app.title == "Deribit Tracker" + assert app.project_metadata["title"] == "Deribit Tracker" def test_version_string_safety(self): """Test version is always a string.""" @@ -175,5 +139,5 @@ def test_version_string_safety(self): importlib.reload(app) - assert isinstance(app.version, str) - assert app.version == "1.0" + assert isinstance(app.project_metadata["version"], str) + assert app.project_metadata["version"] == "0.4.0" diff --git a/tests/test_logger.py b/tests/test_logger.py index 61ea06a..a5ffbee 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -6,7 +6,6 @@ """ import logging -from unittest.mock import Mock, patch from app.core.logger import AppLogger, get_logger @@ -273,26 +272,3 @@ def test_logger_in_different_modules(): 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 index efb4eb0..9c414a9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -31,7 +31,7 @@ def test_api_metadata(): data = response.json() assert "info" in data assert "Deribit Tracker" in data["info"]["title"] - assert "0.3.0" in data["info"]["version"] + assert "0.4.0" in data["info"]["version"] def test_cors_headers(): diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 8cc5455..b254cbc 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,22 +1,18 @@ -from importlib.metadata import metadata - -from app import description, title, version +from app.metadata import project_metadata 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 + assert project_metadata["version"] is not None + assert project_metadata["title"] is not None + assert project_metadata["description"] is not None + assert len(project_metadata["version"]) > 0 + assert len(project_metadata["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 + assert project_metadata["version"] == "0.4.0" def test_title_formatting(): - assert "-" not in title - assert title[0].isupper() + assert "-" not in project_metadata["title"] + assert project_metadata["title"][0].isupper() From cd86736515921a24e0c4e8ab7a6aad240ad90b15 Mon Sep 17 00:00:00 2001 From: script-logic Date: Thu, 29 Jan 2026 17:57:35 +0300 Subject: [PATCH 4/9] feat: dependencies --- .env.example | 6 +- .github/workflows/ci.yml | 19 +- .gitlab-ci.yml | 19 +- .secrets.baseline | 66 +- alembic/env.py | 5 +- app/__init__.py | 16 - app/api/__init__.py | 2 + app/api/exceptions.py | 14 +- app/api/routes.py | 2 +- app/api/v1/__init__.py | 5 - app/api/v1/dependencies.py | 19 - app/api/v1/endpoints/prices.py | 15 +- app/clients/deribit.py | 45 +- app/core/__init__.py | 5 +- app/core/config.py | 103 ++- app/database/__init__.py | 8 +- app/database/deps.py | 25 - app/database/manager.py | 30 +- app/database/models.py | 4 +- app/database/repository.py | 14 +- app/dependencies/__init__.py | 13 + app/dependencies/clients.py | 50 ++ app/dependencies/database.py | 26 + app/dependencies/services.py | 24 + app/main.py | 103 ++- app/metadata.py | 45 - app/tasks/__init__.py | 29 +- app/tasks/celery_app.py | 10 - .../celery.py => tasks/celery_application.py} | 11 +- app/tasks/dependencies.py | 22 + app/tasks/price_collection.py | 133 +-- docker-compose.yml | 3 +- poetry.lock | 779 ++++++++++-------- pyproject.toml | 8 +- tests/conftest.py | 200 ----- tests/test_config.py | 416 ---------- tests/test_initialization.py | 143 ---- tests/test_logger.py | 274 ------ tests/test_main.py | 78 -- tests/test_metadata.py | 18 - 40 files changed, 873 insertions(+), 1934 deletions(-) delete mode 100644 app/api/v1/dependencies.py delete mode 100644 app/database/deps.py create mode 100644 app/dependencies/__init__.py create mode 100644 app/dependencies/clients.py create mode 100644 app/dependencies/database.py create mode 100644 app/dependencies/services.py delete mode 100644 app/metadata.py delete mode 100644 app/tasks/celery_app.py rename app/{core/celery.py => tasks/celery_application.py} (89%) create mode 100644 app/tasks/dependencies.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_config.py delete mode 100644 tests/test_initialization.py delete mode 100644 tests/test_logger.py delete mode 100644 tests/test_main.py delete mode 100644 tests/test_metadata.py diff --git a/.env.example b/.env.example index 41c23d6..1914dff 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,12 @@ 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 @@ -20,8 +18,6 @@ CELERY__TASK_TRACK_STARTED=True APPLICATION__DEBUG=False APPLICATION__API_V1_PREFIX=/api/v1 -APPLICATION__PROJECT_NAME=Deribit Price Tracker API -APPLICATION__VERSION=0.4.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/.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 86c5e1c..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": 44 - } - ], - "tests\\test_config.py": [ - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 33 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e", - "is_verified": false, - "line_number": 74 - }, - { - "type": "Basic Auth Credentials", - "filename": "tests\\test_config.py", - "hashed_secret": "c94d65f02a652d11c2e5c2e1ccf38dce5a076e1e", - "is_verified": false, - "line_number": 79 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "1adfce9fa4bc6b1cbdf95ac2dc6180175da7558b", - "is_verified": false, - "line_number": 90 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0", - "is_verified": false, - "line_number": 122 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "ee27c133da056b1013f88c712f92460bc7b3c90a", - "is_verified": false, - "line_number": 130 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "is_verified": false, - "line_number": 241 - }, - { - "type": "Secret Keyword", - "filename": "tests\\test_config.py", - "hashed_secret": "fca268ae2442d5cabc3e12d87b349adf8bf7d76c", - "is_verified": false, - "line_number": 317 + "line_number": 47 } ] }, - "generated_at": "2026-01-27T20:07:53Z" + "generated_at": "2026-01-29T14:55:03Z" } 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 3892c3d..d4496ab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,19 +1,3 @@ """ Deribit Price Tracker API application package. - -Main application module with metadata and imports. """ - -from . import api, clients, core, database, metadata, services, tasks -from .metadata import project_metadata - -__all__ = [ - "api", - "clients", - "core", - "database", - "metadata", - "project_metadata", - "services", - "tasks", -] diff --git a/app/api/__init__.py b/app/api/__init__.py index ba8a363..dc7a315 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -2,10 +2,12 @@ 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 index 21935bc..de7af5f 100644 --- a/app/api/exceptions.py +++ b/app/api/exceptions.py @@ -2,7 +2,7 @@ Custom exceptions and error handlers for API error handling. """ -from typing import Any, cast +from typing import Any from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError @@ -69,7 +69,7 @@ def __init__(self, service: str, details: dict[str, Any] | None = None): ) -async def http_exception_handler( +async def http_exception_handler( # noqa: RUF029 request: Request, exc: Exception, ) -> JSONResponse: @@ -77,6 +77,8 @@ async def http_exception_handler( 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)", @@ -87,7 +89,7 @@ async def http_exception_handler( ) response_content = { - "detail": cast(str, exc.detail), + "detail": exc.detail, "error_type": exc.error_type, "details": exc.details, } @@ -99,7 +101,7 @@ async def http_exception_handler( ) response_content = { - "detail": str(exc.detail), + "detail": exc.detail, "error_type": "http_error", } @@ -109,7 +111,7 @@ async def http_exception_handler( ) -async def validation_exception_handler( +async def validation_exception_handler( # noqa: RUF029 request: Request, exc: Exception, ) -> JSONResponse: @@ -132,7 +134,7 @@ async def validation_exception_handler( ) -async def generic_exception_handler( +async def generic_exception_handler( # noqa: RUF029 request: Request, exc: Exception, ) -> JSONResponse: diff --git a/app/api/routes.py b/app/api/routes.py index feb8e40..20934fe 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -6,6 +6,7 @@ from .v1.endpoints import prices_router +api_v1_router = APIRouter() root_router = APIRouter() health_check_router = APIRouter() @@ -20,7 +21,6 @@ async def health_check(): return {"status": "healthy"} -api_v1_router = APIRouter() api_v1_router.include_router( prices_router, prefix="/prices", diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index 991c6e1..e69de29 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -1,5 +0,0 @@ -from .dependencies import get_price_service - -__all__ = [ - "get_price_service", -] diff --git a/app/api/v1/dependencies.py b/app/api/v1/dependencies.py deleted file mode 100644 index 1566c54..0000000 --- a/app/api/v1/dependencies.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core import get_logger -from app.database.deps import get_db_session -from app.database.repository import PriceRepository -from app.services.price_service import PriceService - -logger = get_logger(__name__) - - -def get_price_service( - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> PriceService: - """Dependency for PriceService.""" - repository = PriceRepository(session) - return PriceService(repository) diff --git a/app/api/v1/endpoints/prices.py b/app/api/v1/endpoints/prices.py index 0730316..b48012f 100644 --- a/app/api/v1/endpoints/prices.py +++ b/app/api/v1/endpoints/prices.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from app.api.exceptions import NotFoundError -from app.api.v1.dependencies import get_price_service from app.api.v1.schemas import ( DateFilterParams, ErrorResponse, @@ -16,7 +15,7 @@ PriceTickResponse, ) from app.core import get_logger -from app.services.price_service import PriceService +from app.dependencies import PriceServiceDep logger = get_logger(__name__) @@ -46,7 +45,7 @@ async def get_all_prices( ), ], pagination: Annotated[PaginationParams, Depends()], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, ) -> list[PriceTickResponse]: """ Get all price records for specified ticker. @@ -100,7 +99,7 @@ async def get_latest_price( examples=["btc_usd", "eth_usd"], ), ], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, ) -> PriceTickResponse: """ Get latest price for ticker. @@ -161,7 +160,7 @@ async def get_price_at_timestamp( ge=0, ), ], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, ) -> PriceTickResponse: """ Get price at exact timestamp. @@ -238,7 +237,7 @@ async def get_price_closest_to_timestamp( ge=0, ), ], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, max_difference: Annotated[ int, Query( @@ -319,7 +318,7 @@ async def get_prices_by_date( ), ], date_filter: Annotated[DateFilterParams, Depends()], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, ) -> list[PriceTickResponse]: """ Get prices by date range. @@ -382,7 +381,7 @@ async def get_price_statistics( examples=["btc_usd", "eth_usd"], ), ], - price_service: Annotated[PriceService, Depends(get_price_service)], + price_service: PriceServiceDep, timestamp: Annotated[ int | None, Query( diff --git a/app/clients/deribit.py b/app/clients/deribit.py index 42fe6f6..cda7caf 100644 --- a/app/clients/deribit.py +++ b/app/clients/deribit.py @@ -11,16 +11,15 @@ import aiohttp from aiohttp import ClientError, ClientResponseError, ClientTimeout -from app.core import get_logger, settings +from app.core import get_logger from .exceptions import DeribitAPIError -logger = get_logger(__name__) - 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. @@ -29,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 @@ -49,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, @@ -101,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( @@ -123,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, @@ -139,7 +140,7 @@ 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...", error, wait_time, @@ -154,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, ) @@ -205,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: @@ -234,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 @@ -245,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/core/__init__.py b/app/core/__init__.py index 786fd79..c0fe3f3 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -6,10 +6,11 @@ centralized logging. """ -from .config import settings +from .config import Settings, get_settings from .logger import get_logger __all__ = [ + "Settings", "get_logger", - "settings", + "get_settings", ] diff --git a/app/core/config.py b/app/core/config.py index 2d323c8..a3b2618 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,8 +6,11 @@ """ 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 @@ -139,11 +142,33 @@ class ApplicationSettings(BaseModel): debug: bool = False api_v1_prefix: str = "/api/v1" - project_name: str = "Deribit Price Tracker API" - version: str = "0.4.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: @@ -170,6 +195,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): """ @@ -183,6 +248,9 @@ 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] = ["*"] model_config = {"frozen": True} @@ -237,23 +305,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, @@ -261,29 +329,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.warning( "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 - - -try: - settings = get_settings() -except Exception as e: - logging.error("Failed to initialize settings: %s", e) - raise + return Settings.init_instance() 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..1f82c65 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, @@ -87,6 +88,24 @@ async def get_session(self) -> AsyncGenerator[AsyncSession, None]: AsyncSession: Database session. """ session = self._get_session_factory()() + logger.debug("=================================== %s, session") + + try: + yield session + await session.commit() + except Exception as error: + await session.rollback() + raise error + 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 @@ -112,8 +131,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 6c7ec1e..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, 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. 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/main.py b/app/main.py index 3e3d0b9..8094848 100644 --- a/app/main.py +++ b/app/main.py @@ -1,47 +1,68 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from . import project_metadata -from .api import api_v1_router, health_check_router, root_router -from .api.exceptions import register_exception_handlers -from .core import get_logger, settings - -logger = get_logger(__name__) +from .api import ( + api_v1_router, + health_check_router, + register_exception_handlers, + root_router, +) +from .core import get_logger, get_settings def create_app() -> FastAPI: - app = FastAPI( - title=project_metadata["title"], - version=project_metadata["version"], - description=project_metadata["description"], - openapi_url=f"{settings.application.api_v1_prefix}/openapi.json", - docs_url="/docs", - redoc_url="/redoc", - debug=settings.application.debug, - ) - - register_exception_handlers(app) - - app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors.origins, - allow_credentials=True, - allow_methods=["GET", "OPTIONS"], - allow_headers=["*"], - ) - - app.include_router( - api_v1_router, - prefix=settings.application.api_v1_prefix, - ) - app.include_router(root_router) - app.include_router(health_check_router) - - return app - - -try: - app: FastAPI = create_app() -except Exception as e: - logger.error("FastAPI app creation error: %s", e) - raise + + logger = get_logger(__name__) + + try: + settings = get_settings() + app_config = settings.application + cors_config = settings.cors + + 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=( + f"{app_config.api_v1_prefix}/{app_config.openapi_json}" + ), + ) + + 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( + api_v1_router, + prefix=app_config.api_v1_prefix, + ) + app.include_router(root_router) + app.include_router(health_check_router) + + 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/metadata.py b/app/metadata.py deleted file mode 100644 index 78713a9..0000000 --- a/app/metadata.py +++ /dev/null @@ -1,45 +0,0 @@ -from pathlib import Path - -import toml - -from app.core.logger import get_logger - -logger = get_logger(__name__) - - -def get_project_metadata() -> 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 application title (e.g., "Deribit Tracker") - """ - pyproject_path = Path(__file__).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: - logger.warning(f"Warning: Could not read pyproject.toml: {e}") - - return { - "title": "Unknown", - "version": "Unknown", - "description": "", - } - - -project_metadata: dict[str, str] = get_project_metadata() 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..7efa658 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): # 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..f8a8d69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,7 @@ services: --loglevel=info --concurrency=${CELERY__WORKER_CONCURRENCY:-2} --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 b70621f..7cdfe24 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" @@ -2954,14 +2946,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 +3041,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 +3224,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 +3442,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 +3526,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] @@ -3828,6 +3820,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" @@ -4151,14 +4202,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]] @@ -4379,5 +4430,5 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" -python-versions = ">=3.11" -content-hash = "d26030b3deb1134b4c01aa5eacb1ef0fb07d0ead4d895e263b89262e031983a1" +python-versions = ">=3.11,<4" +content-hash = "a503c02dfb6a9832b4432bffb976f502607dc87ef74b6aebfbb3b315182920c5" diff --git a/pyproject.toml b/pyproject.toml index 7695742..ccb6924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "poetry.core.masonry.api" [project] name = "deribit-tracker" -version = "0.4.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"} @@ -18,12 +18,13 @@ 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)", ] [tool.poetry] @@ -41,6 +42,7 @@ bandit = "^1.9.3" safety = "^3.7.0" detect-secrets = "^1.5.0" httpx = "^0.28.1" +celery-types = "^0.24.0" [tool.ruff] target-version = "py311" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 9f2179f..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -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 - -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. - - 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 -def mock_async_session(): - """ - Mock async database session for testing. - - Returns: - Mock AsyncSession with common methods mocked. - """ - session = AsyncMock(spec=AsyncSession) - - session.execute = 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() diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index e93d27c..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,416 +0,0 @@ -""" -Unit tests for application configuration management. - -Tests the settings loading, validation, and singleton behavior -of the configuration system. -""" - -import os -from copy import copy -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.4.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 copy(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_initialization.py b/tests/test_initialization.py deleted file mode 100644 index 6c0dfc8..0000000 --- a/tests/test_initialization.py +++ /dev/null @@ -1,143 +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.metadata - - importlib.reload(app.metadata) - - assert hasattr(app.metadata, "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_module_exports(self): - """Test that module exports expected symbols.""" - import app - - expected_exports = { - "api", - "clients", - "core", - "database", - "project_metadata", - "services", - "tasks", - "metadata", - } - - 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.project_metadata["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.project_metadata["version"], str) - assert app.project_metadata["version"] == "0.4.0" diff --git a/tests/test_logger.py b/tests/test_logger.py deleted file mode 100644 index a5ffbee..0000000 --- a/tests/test_logger.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -Unit tests for application logging system. - -Tests logger configuration, log level management, and logging behavior -across different scenarios. -""" - -import logging - -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 diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 9c414a9..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,78 +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": "Deribit Price Tracker API is running", - } - - -def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} - - -def test_api_metadata(): - from app.core import settings - - response = client.get(f"{settings.application.api_v1_prefix}/openapi.json") - assert response.status_code == 200 - - data = response.json() - assert "info" in data - assert "Deribit Tracker" in data["info"]["title"] - assert "0.4.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 b254cbc..0000000 --- a/tests/test_metadata.py +++ /dev/null @@ -1,18 +0,0 @@ -from app.metadata import project_metadata - - -def test_package_metadata_loaded(): - assert project_metadata["version"] is not None - assert project_metadata["title"] is not None - assert project_metadata["description"] is not None - assert len(project_metadata["version"]) > 0 - assert len(project_metadata["title"]) > 0 - - -def test_version_matches_pyproject(): - assert project_metadata["version"] == "0.4.0" - - -def test_title_formatting(): - assert "-" not in project_metadata["title"] - assert project_metadata["title"][0].isupper() From 08d31ae946a9fb0f684ab1b739d5cfdc167fc120 Mon Sep 17 00:00:00 2001 From: script-logic Date: Thu, 29 Jan 2026 22:21:37 +0300 Subject: [PATCH 5/9] feat: unit tests --- app/database/manager.py | 1 - app/tasks/price_collection.py | 2 +- poetry.lock | 36 ++++++- pyproject.toml | 16 ++- test.db | Bin 0 -> 20480 bytes tests/__init__.py | 3 + tests/conftest.py | 36 +++++++ tests/test_api/__init__.py | 0 tests/test_api/test_endpoints.py | 118 ++++++++++++++++++++++ tests/test_clients/__init__.py | 0 tests/test_clients/test_deribit.py | 113 +++++++++++++++++++++ tests/test_database/__init__.py | 0 tests/test_database/test_repository.py | 104 +++++++++++++++++++ tests/test_services/__init__.py | 0 tests/test_services/test_price_service.py | 93 +++++++++++++++++ tests/test_tasks/__init__.py | 0 tests/test_tasks/test_celery.py | 47 +++++++++ 17 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 test.db create mode 100644 tests/conftest.py create mode 100644 tests/test_api/__init__.py create mode 100644 tests/test_api/test_endpoints.py create mode 100644 tests/test_clients/__init__.py create mode 100644 tests/test_clients/test_deribit.py create mode 100644 tests/test_database/__init__.py create mode 100644 tests/test_database/test_repository.py create mode 100644 tests/test_services/__init__.py create mode 100644 tests/test_services/test_price_service.py create mode 100644 tests/test_tasks/__init__.py create mode 100644 tests/test_tasks/test_celery.py diff --git a/app/database/manager.py b/app/database/manager.py index 1f82c65..7f52e0e 100644 --- a/app/database/manager.py +++ b/app/database/manager.py @@ -88,7 +88,6 @@ async def get_session(self) -> AsyncGenerator[AsyncSession, None]: AsyncSession: Database session. """ session = self._get_session_factory()() - logger.debug("=================================== %s, session") try: yield session diff --git a/app/tasks/price_collection.py b/app/tasks/price_collection.py index 7efa658..fb304aa 100644 --- a/app/tasks/price_collection.py +++ b/app/tasks/price_collection.py @@ -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: Task): # type: ignore +def collect_all_prices(self: Task) -> dict[str, Any]: # type: ignore """ Celery task to collect prices for all supported tickers. diff --git a/poetry.lock b/poetry.lock index 7cdfe24..720ec0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -170,6 +170,22 @@ files = [ frozenlist = ">=1.1.0" typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} +[[package]] +name = "aiosqlite" +version = "0.22.1" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, + {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, +] + +[package.extras] +dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] + [[package]] name = "alembic" version = "1.18.1" @@ -2897,6 +2913,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" @@ -4431,4 +4465,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.11,<4" -content-hash = "a503c02dfb6a9832b4432bffb976f502607dc87ef74b6aebfbb3b315182920c5" +content-hash = "e0f71e9beb99ac0dcadd9a1a76214baf199f33c225afba60ebe804793f563d62" diff --git a/pyproject.toml b/pyproject.toml index ccb6924..15393b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ safety = "^3.7.0" detect-secrets = "^1.5.0" httpx = "^0.28.1" celery-types = "^0.24.0" +pytest-mock = "^3.15.1" +aiosqlite = "^0.22.1" [tool.ruff] target-version = "py311" @@ -78,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] @@ -86,7 +94,8 @@ source = ["app"] omit = [ "*/__pycache__/*", "*/tests/*", - "*/migrations/*" + "*/migrations/*", + "app/main.py" ] [tool.coverage.report] @@ -98,5 +107,6 @@ exclude_lines = [ "raise AssertionError", "raise NotImplementedError", "if 0:", - "if __name__ == .__main__.:" + "if __name__ == .__main__.:", + "pass" ] diff --git a/test.db b/test.db new file mode 100644 index 0000000000000000000000000000000000000000..1cb0a046e4158c710ffa00ae50f74c851570bd42 GIT binary patch literal 20480 zcmeI(Z)?*)9Ki8Q*QqNM_N-5T&?gPZaEk1aVAf_DOw*aA;vR+Ao)TQQnMT-C5qvLu zb9)WmWi6zo^wA#Z_$FL($^H3VKDj4H$l1p*FSUHVDrQ+JuhhPB9Q9U2Db+M-cady< znM}6YoMmRqxv38RxNTcuU#a%p{l29>MF=2(00IagfB*srAb$aDb9<~sgAL&pB{t$wr`vUYAC}pd3tj8FmTfGeB;Il8uMWJmmrvz5co#&8 zL?e^NVfb8Hd-o3{pS-yL-iv!LU$`s9Rp)XNj=c3|%OP**b5iK6)YH_gxCaI}35LE5 z{1a~+Cek0ru^%O=y?K^+!_$>;r*Sa!;&b`npGz;Fx-Iv0<4GszI;w)6|F|%#`84}k zURBpC@At3>$SXF~u1 z1Q0*~0R#|0009ILKmdWqE5Q2y@xEK03;_fXKmY**5I_I{1Q0*~fi(g9KY;ze-<}(5 bWn3bF00IagfB*srAb Settings: + """Mock application settings.""" + return Settings.init_instance() + + +@pytest.fixture +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() + return session + + +@pytest.fixture +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_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_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 From 5d0048f2c90106d02e56e2449384eebf23be793b Mon Sep 17 00:00:00 2001 From: script-logic Date: Fri, 30 Jan 2026 01:24:56 +0300 Subject: [PATCH 6/9] feat: frontend --- .dockerignore | 1 + .gitignore | 1 + app/api/routes.py | 5 - app/core/config.py | 3 - app/frontend/__init__.py | 7 + app/frontend/routes.py | 52 ++++++++ app/frontend/static/css/style.css | 206 ++++++++++++++++++++++++++++++ app/frontend/templates/index.html | 164 ++++++++++++++++++++++++ app/main.py | 10 +- poetry.lock | 2 +- pyproject.toml | 1 + 11 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 app/frontend/__init__.py create mode 100644 app/frontend/routes.py create mode 100644 app/frontend/static/css/style.css create mode 100644 app/frontend/templates/index.html 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/.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/app/api/routes.py b/app/api/routes.py index 20934fe..f928ef3 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -11,11 +11,6 @@ health_check_router = APIRouter() -@root_router.get("/") -async def root(): - return {"message": "Deribit Price Tracker API is running"} - - @health_check_router.get("/health") async def health_check(): return {"status": "healthy"} diff --git a/app/core/config.py b/app/core/config.py index a3b2618..c6d1d17 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -262,9 +262,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 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..f22413b --- /dev/null +++ b/app/frontend/static/css/style.css @@ -0,0 +1,206 @@ +: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; +} + +* { + 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; +} + +/* 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: 2; + pointer-events: none; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + position: relative; + z-index: 3; +} + +/* Header */ +header { + border-bottom: 2px solid var(--neon-pink); + padding-bottom: 20px; + margin-bottom: 40px; + display: flex; + justify-content: space-between; + align-items: flex-end; + box-shadow: 0 0 15px var(--neon-pink); +} + +h1 { + font-size: 2.5rem; + color: var(--neon-blue); + text-shadow: 2px 2px var(--neon-purple); + text-transform: uppercase; + letter-spacing: 4px; +} + +.status-badge { + border: 1px solid var(--neon-blue); + padding: 5px 15px; + color: var(--neon-blue); + box-shadow: 0 0 5px var(--neon-blue); + animation: blink 2s infinite; +} + +/* Dashboard Grid */ +.dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 30px; + margin-bottom: 40px; +} + +.card { + background: rgba(1, 1, 43, 0.9); + border: 1px solid var(--neon-purple); + padding: 20px; + position: relative; + box-shadow: 0 0 10px rgba(211, 4, 204, 0.3); +} + +.card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 20px rgba(211, 4, 204, 0.1); + pointer-events: none; +} + +.card h2 { + color: var(--neon-pink); + margin-bottom: 15px; + border-bottom: 1px dashed var(--neon-blue); + padding-bottom: 5px; +} + +.price-display { + font-size: 2.5rem; + color: #fff; + text-shadow: 0 0 10px #fff; +} + +.ticker-label { + font-size: 0.8rem; + color: var(--neon-blue); + opacity: 0.8; +} + +/* Data Table */ +.data-panel { + border: 1px solid var(--neon-blue); + background: rgba(0, 0, 0, 0.8); + padding: 20px; +} + +.controls { + margin-bottom: 20px; + display: flex; + gap: 15px; +} + +select, +button { + background: transparent; + border: 1px solid var(--neon-blue); + color: var(--neon-blue); + padding: 10px 20px; + font-family: var(--terminal-font); + cursor: pointer; + transition: all 0.3s; + text-transform: uppercase; +} + +button:hover { + background: var(--neon-blue); + color: var(--dark-bg); + box-shadow: 0 0 15px var(--neon-blue); +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} + +th { + text-align: left; + color: var(--neon-pink); + border-bottom: 2px solid var(--neon-purple); + padding: 10px; +} + +td { + padding: 10px; + border-bottom: 1px solid rgba(5, 217, 232, 0.3); + color: var(--text-main); +} + +tr:hover td { + background: rgba(5, 217, 232, 0.1); + color: #fff; +} + +@keyframes blink { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } + + 100% { + opacity: 1; + } +} + +@media (max-width: 768px) { + .dashboard { + grid-template-columns: 1fr; + } + + header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } +} diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html new file mode 100644 index 0000000..e259f02 --- /dev/null +++ b/app/frontend/templates/index.html @@ -0,0 +1,164 @@ + + + + + + + {{ project_name }} // DASHBOARD + + + + + + + + + +
+
+
+

{{ project_name }}

+
+ SYS.VER: {{ version }} // CONNECTED +
+
+
+ ● SYSTEM ONLINE +
+
+ + +
+
+

>> BTC_USD

+
LOADING...
+
INDEX_PRICE // LIVE
+
+ +
+

>> ETH_USD

+
LOADING...
+
INDEX_PRICE // LIVE
+
+
+ + +
+

+ >> ARCHIVE_ACCESS +

+ +
+ + +
+ +
+ + + + + + + + + + + + +
TIMESTAMP (UTC)TICKERPRICE (USD)ID
+
+
+
+ + + + + diff --git a/app/main.py b/app/main.py index 8094848..1a369bb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,14 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from .api import ( api_v1_router, health_check_router, register_exception_handlers, - root_router, ) from .core import get_logger, get_settings +from .frontend import router as frontend_router def create_app() -> FastAPI: @@ -45,7 +46,12 @@ def create_app() -> FastAPI: api_v1_router, prefix=app_config.api_v1_prefix, ) - app.include_router(root_router) + app.mount( + "/static", + StaticFiles(directory="app/frontend/static"), + name="static", + ) + app.include_router(frontend_router) app.include_router(health_check_router) logger.info( diff --git a/poetry.lock b/poetry.lock index 720ec0a..4b053a4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4465,4 +4465,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.11,<4" -content-hash = "e0f71e9beb99ac0dcadd9a1a76214baf199f33c225afba60ebe804793f563d62" +content-hash = "9b2ab62eb8212fd1cabadf902e63e516be9f01a9190e265eca2bdf5afae78daf" diff --git a/pyproject.toml b/pyproject.toml index 15393b3..af1ec40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "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] From ef65427288cc03adf1fc706c9aa22a6326cd0314 Mon Sep 17 00:00:00 2001 From: script-logic Date: Fri, 30 Jan 2026 14:18:35 +0300 Subject: [PATCH 7/9] fix: some misses --- .env.example | 2 +- Dockerfile | 2 - README.md | 568 +++++++++++++++++++++++++++++++++++++++++++++ app/core/config.py | 30 ++- app/main.py | 13 +- docker-compose.yml | 4 +- poetry.lock | 18 +- pyproject.toml | 2 - test.db | Bin 20480 -> 0 bytes 9 files changed, 606 insertions(+), 33 deletions(-) delete mode 100644 test.db diff --git a/.env.example b/.env.example index 1914dff..5457ad0 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ REDIS__DB=0 REDIS__PASSWORD= REDIS__SSL=False -CELERY__WORKER_CONCURRENCY=2 +CELERY__WORKER_CONCURRENCY=1 CELERY__BEAT_ENABLED=True CELERY__TASK_TRACK_STARTED=True 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..ad178a6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,568 @@ +
+ +# Deribit Price Tracker + +[English](#english) | [Русский](#russian) + +![Python](https://img.shields.io/badge/Python-3.11-blue.svg) +![FastAPI](https://img.shields.io/badge/FastAPI-0.104-green.svg) +![Celery](https://img.shields.io/badge/Celery-5.3-orange.svg) +![License](https://img.shields.io/badge/license-MIT-blue.svg) +[![CI Status](https://github.com/script-logic/deribit-tracker/actions/workflows/ci.yml/badge.svg)](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/yourusername/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 + +# 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 TB + subgraph External + DB[(PostgreSQL)] + Redis[(Redis)] + Deribit[Deribit API] + end + + subgraph Application + API[FastAPI Application] + Celery[Celery Workers] + Beat[Celery Beat] + + subgraph Services + PS[Price Service] + Rep[Repository] + Client[Deribit Client] + end + + subgraph Tasks + PC[Price Collection] + HC[Health Check] + end + end + + Client --> Deribit + API --> PS + PS --> Rep + Rep --> DB + + Celery --> PC + PC --> Client + PC --> Rep + Beat --> Redis + Redis --> Celery + + HC --> |Monitor| Client + HC --> |Monitor| DB + HC --> |Monitor| Redis +``` +## Components Overview + +├── .github/ # +│ └── workflows/ # +│ └── ci.yml # +├── alembic/ # +│ ├── versions/ # +│ │ └── 2026/ # +│ │ └── 01/ # +│ │ └── 25_2149_52_19cfef6b2cba_create_price_ticks_table.py # +│ ├── README # +│ ├── env.py # +│ └── script.py.mako # +├── app/ # +│ ├── api/ # +│ │ ├── v1/ # +│ │ │ ├── endpoints/ # +│ │ │ │ ├── __init__.py # +│ │ │ │ └── prices.py # +│ │ │ ├── __init__.py # +│ │ │ └── schemas.py # +│ │ ├── __init__.py # +│ │ ├── exceptions.py # +│ │ └── routes.py # +│ ├── clients/ # +│ │ ├── __init__.py # +│ │ ├── deribit.py # +│ │ └── exceptions.py # +│ ├── core/ # +│ │ ├── __init__.py # +│ │ ├── config.py # +│ │ └── logger.py # +│ ├── database/ # +│ │ ├── __init__.py # +│ │ ├── base.py # +│ │ ├── manager.py # +│ │ ├── models.py # +│ │ └── repository.py # +│ ├── dependencies/ # +│ │ ├── __init__.py # +│ │ ├── clients.py # +│ │ ├── database.py # +│ │ └── services.py # +│ ├── frontend/ # +│ │ ├── static/ # +│ │ │ └── css/ # +│ │ │ └── style.css # +│ │ ├── templates/ # +│ │ │ └── index.html # +│ │ ├── __init__.py # +│ │ └── routes.py # +│ ├── services/ # +│ │ ├── __init__.py # +│ │ └── price_service.py # +│ ├── tasks/ # +│ │ ├── __init__.py # +│ │ ├── celery_application.py # +│ │ ├── dependencies.py # +│ │ └── price_collection.py # +│ ├── __init__.py # +│ └── main.py # +├── tests/ # +│ ├── test_api/ # +│ │ ├── __init__.py # +│ │ └── test_endpoints.py # +│ ├── test_clients/ # +│ │ ├── __init__.py # +│ │ └── test_deribit.py # +│ ├── test_database/ # +│ │ ├── __init__.py # +│ │ └── test_repository.py # +│ ├── test_services/ # +│ │ ├── __init__.py # +│ │ └── test_price_service.py # +│ ├── test_tasks/ # +│ │ ├── __init__.py # +│ │ └── test_celery.py # +│ ├── __init__.py # +│ └── conftest.py # +├── .bandit.yml # +├── .dockerignore # +├── .env.example # +├── .gitignore # +├── .gitlab-ci.yml # +├── .pre-commit-config.yaml # +├── .secrets.baseline # +├── Dockerfile # +├── LICENSE # +├── README.md # +├── alembic.ini # +├── docker-compose.yml # +├── poetry.lock # +├── pyproject.toml # +└── test.db # + +### Core Components +- **FastAPI Application**: Main web server handling HTTP requests +- **Celery Workers**: Distributed task processing +- **PostgreSQL**: Primary data storage +- **Redis**: Message broker and task results backend + +### Service Layer +- **Price Service**: Business logic implementation +- **Repository**: Data access abstraction +- **Deribit Client**: External API integration + +### Task Processing +- **Price Collection**: Scheduled price fetching +- **Health Check**: System monitoring +- **Celery Beat**: Task scheduling + +## 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 + - Integration tests + - Async test support + - Mock frameworks for external services + - CI/CD pipeline with GitHub Actions + +### Monitoring +- **Logging and metrics**: + - Structured logging + - Health check endpoints + - Performance monitoring + - Error tracking + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +
+ + + + diff --git a/app/core/config.py b/app/core/config.py index c6d1d17..49b85d6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -95,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) @@ -122,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 @@ -131,13 +142,18 @@ 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 @@ -250,7 +266,13 @@ class CORSSettings(BaseModel): ] allow_credentials: bool = True allow_methods: list[str] = ["GET", "OPTIONS"] - allow_headers: list[str] = ["*"] + allow_headers: list[str] = [ + "Content-Type", + "Authorization", + "Accept", + "Origin", + "X-Requested-With", + ] model_config = {"frozen": True} @@ -328,7 +350,7 @@ def _log_initialization(self) -> None: if self.deribit_api.is_configured: logger.info("Deribit API credentials configured") else: - logger.warning( + logger.info( "Deribit API credentials not configured - " "only public endpoints available" ) diff --git a/app/main.py b/app/main.py index 1a369bb..0f2a5e7 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,10 @@ def create_app() -> FastAPI: settings = get_settings() app_config = settings.application cors_config = settings.cors + openapi_url = "/".join([ + app_config.api_v1_prefix, + app_config.openapi_json, + ]) app = FastAPI( title=app_config.project_name, @@ -27,9 +31,7 @@ def create_app() -> FastAPI: docs_url=app_config.docs_url, redoc_url=app_config.redoc_url, debug=app_config.debug, - openapi_url=( - f"{app_config.api_v1_prefix}/{app_config.openapi_json}" - ), + openapi_url=openapi_url, ) register_exception_handlers(app) @@ -42,17 +44,18 @@ def create_app() -> FastAPI: 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", ) - app.include_router(frontend_router) - app.include_router(health_check_router) logger.info( "FastAPI application successfully initialized: %s", diff --git a/docker-compose.yml b/docker-compose.yml index f8a8d69..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,7 +84,7 @@ 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 env_file: diff --git a/poetry.lock b/poetry.lock index 4b053a4..962a7e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -170,22 +170,6 @@ files = [ frozenlist = ">=1.1.0" typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} -[[package]] -name = "aiosqlite" -version = "0.22.1" -description = "asyncio bridge to the standard sqlite3 module" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"}, - {file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"}, -] - -[package.extras] -dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"] -docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"] - [[package]] name = "alembic" version = "1.18.1" @@ -4465,4 +4449,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.11,<4" -content-hash = "9b2ab62eb8212fd1cabadf902e63e516be9f01a9190e265eca2bdf5afae78daf" +content-hash = "601f2679d45e7bd26580f86bfdeb6324ae6fa5d57ee7f80053fa2469815eaa0a" diff --git a/pyproject.toml b/pyproject.toml index af1ec40..436cc7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ detect-secrets = "^1.5.0" httpx = "^0.28.1" celery-types = "^0.24.0" pytest-mock = "^3.15.1" -aiosqlite = "^0.22.1" [tool.ruff] target-version = "py311" @@ -96,7 +95,6 @@ omit = [ "*/__pycache__/*", "*/tests/*", "*/migrations/*", - "app/main.py" ] [tool.coverage.report] diff --git a/test.db b/test.db deleted file mode 100644 index 1cb0a046e4158c710ffa00ae50f74c851570bd42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI(Z)?*)9Ki8Q*QqNM_N-5T&?gPZaEk1aVAf_DOw*aA;vR+Ao)TQQnMT-C5qvLu zb9)WmWi6zo^wA#Z_$FL($^H3VKDj4H$l1p*FSUHVDrQ+JuhhPB9Q9U2Db+M-cady< znM}6YoMmRqxv38RxNTcuU#a%p{l29>MF=2(00IagfB*srAb$aDb9<~sgAL&pB{t$wr`vUYAC}pd3tj8FmTfGeB;Il8uMWJmmrvz5co#&8 zL?e^NVfb8Hd-o3{pS-yL-iv!LU$`s9Rp)XNj=c3|%OP**b5iK6)YH_gxCaI}35LE5 z{1a~+Cek0ru^%O=y?K^+!_$>;r*Sa!;&b`npGz;Fx-Iv0<4GszI;w)6|F|%#`84}k zURBpC@At3>$SXF~u1 z1Q0*~0R#|0009ILKmdWqE5Q2y@xEK03;_fXKmY**5I_I{1Q0*~fi(g9KY;ze-<}(5 bWn3bF00IagfB*srAb Date: Fri, 30 Jan 2026 14:54:48 +0300 Subject: [PATCH 8/9] fix: readme --- README.md | 351 +++++--------------- app/frontend/static/css/style.css | 484 +++++++++++++++++++++++++--- app/frontend/templates/index.html | 510 ++++++++++++++++++++++++++---- 3 files changed, 959 insertions(+), 386 deletions(-) diff --git a/README.md b/README.md index ad178a6..13c01c7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Deribit Price Tracker is a high-performance cryptocurrency price monitoring syst 1. Clone the repository: ```bash -git clone https://github.com/yourusername/deribit-tracker.git +git clone https://github.com/script-logic/deribit-tracker.git cd deribit-tracker ``` @@ -72,7 +72,7 @@ docker-compose up -d ### Setup Development Environment ```bash # Install dependencies -poetry install +poetry install --with dev # Apply migrations poetry run alembic upgrade head @@ -86,155 +86,57 @@ poetry run pytest ## Architecture ```mermaid -flowchart TB +flowchart LR + %% --- Стилизация --- + classDef db fill:#ffcc80,stroke:#ef6c00,color:black,stroke-width:2px; + + %% --- Узлы --- subgraph External - DB[(PostgreSQL)] - Redis[(Redis)] - Deribit[Deribit API] + direction TB + DB[(PostgreSQL)]:::db + Deribit[Deribit API]:::ext + end + + subgraph Infrastructure + Redis[(Redis)]:::db end subgraph Application - API[FastAPI Application] - Celery[Celery Workers] - Beat[Celery Beat] - - subgraph Services - PS[Price Service] - Rep[Repository] - Client[Deribit Client] + API[FastAPI App]:::app + + subgraph Logic + direction TB + PS[Price Service]:::app + Rep[Repository]:::app + Client[Deribit Client]:::app end - subgraph Tasks - PC[Price Collection] - HC[Health Check] + subgraph Workers + direction TB + Beat[Celery Beat]:::task + Celery[Celery Worker]:::task + PC[Tasks: Price Collection]:::task end + + HC(Health Check):::task end - Client --> Deribit API --> PS PS --> Rep - Rep --> DB + Rep ==> DB + Beat --> Redis + Redis --> Celery Celery --> PC PC --> Client PC --> Rep - Beat --> Redis - Redis --> Celery - HC --> |Monitor| Client - HC --> |Monitor| DB - HC --> |Monitor| Redis + Client -- HTTP/WS --> Deribit + + HC -.-> |ping| Client + HC -.-> |ping| DB + HC -.-> |ping| Redis ``` -## Components Overview - -├── .github/ # -│ └── workflows/ # -│ └── ci.yml # -├── alembic/ # -│ ├── versions/ # -│ │ └── 2026/ # -│ │ └── 01/ # -│ │ └── 25_2149_52_19cfef6b2cba_create_price_ticks_table.py # -│ ├── README # -│ ├── env.py # -│ └── script.py.mako # -├── app/ # -│ ├── api/ # -│ │ ├── v1/ # -│ │ │ ├── endpoints/ # -│ │ │ │ ├── __init__.py # -│ │ │ │ └── prices.py # -│ │ │ ├── __init__.py # -│ │ │ └── schemas.py # -│ │ ├── __init__.py # -│ │ ├── exceptions.py # -│ │ └── routes.py # -│ ├── clients/ # -│ │ ├── __init__.py # -│ │ ├── deribit.py # -│ │ └── exceptions.py # -│ ├── core/ # -│ │ ├── __init__.py # -│ │ ├── config.py # -│ │ └── logger.py # -│ ├── database/ # -│ │ ├── __init__.py # -│ │ ├── base.py # -│ │ ├── manager.py # -│ │ ├── models.py # -│ │ └── repository.py # -│ ├── dependencies/ # -│ │ ├── __init__.py # -│ │ ├── clients.py # -│ │ ├── database.py # -│ │ └── services.py # -│ ├── frontend/ # -│ │ ├── static/ # -│ │ │ └── css/ # -│ │ │ └── style.css # -│ │ ├── templates/ # -│ │ │ └── index.html # -│ │ ├── __init__.py # -│ │ └── routes.py # -│ ├── services/ # -│ │ ├── __init__.py # -│ │ └── price_service.py # -│ ├── tasks/ # -│ │ ├── __init__.py # -│ │ ├── celery_application.py # -│ │ ├── dependencies.py # -│ │ └── price_collection.py # -│ ├── __init__.py # -│ └── main.py # -├── tests/ # -│ ├── test_api/ # -│ │ ├── __init__.py # -│ │ └── test_endpoints.py # -│ ├── test_clients/ # -│ │ ├── __init__.py # -│ │ └── test_deribit.py # -│ ├── test_database/ # -│ │ ├── __init__.py # -│ │ └── test_repository.py # -│ ├── test_services/ # -│ │ ├── __init__.py # -│ │ └── test_price_service.py # -│ ├── test_tasks/ # -│ │ ├── __init__.py # -│ │ └── test_celery.py # -│ ├── __init__.py # -│ └── conftest.py # -├── .bandit.yml # -├── .dockerignore # -├── .env.example # -├── .gitignore # -├── .gitlab-ci.yml # -├── .pre-commit-config.yaml # -├── .secrets.baseline # -├── Dockerfile # -├── LICENSE # -├── README.md # -├── alembic.ini # -├── docker-compose.yml # -├── poetry.lock # -├── pyproject.toml # -└── test.db # - -### Core Components -- **FastAPI Application**: Main web server handling HTTP requests -- **Celery Workers**: Distributed task processing -- **PostgreSQL**: Primary data storage -- **Redis**: Message broker and task results backend - -### Service Layer -- **Price Service**: Business logic implementation -- **Repository**: Data access abstraction -- **Deribit Client**: External API integration - -### Task Processing -- **Price Collection**: Scheduled price fetching -- **Health Check**: System monitoring -- **Celery Beat**: Task scheduling ## Design Decisions @@ -269,7 +171,6 @@ flowchart TB ### Testing - **Comprehensive test suite**: - Unit tests with pytest - - Integration tests - Async test support - Mock frameworks for external services - CI/CD pipeline with GitHub Actions @@ -278,7 +179,6 @@ flowchart TB - **Logging and metrics**: - Structured logging - Health check endpoints - - Performance monitoring - Error tracking ## License @@ -310,7 +210,7 @@ Deribit Price Tracker - это высокопроизводительная си 1. Клонируйте репозиторий: ```bash -git clone https://github.com/yourusername/deribit-tracker.git +git clone https://github.com/script-logic/deribit-tracker.git cd deribit-tracker ``` @@ -349,7 +249,7 @@ docker-compose up -d ### Настройка окружения разработки ```bash # Установка зависимостей -poetry install +poetry install --with dev # Применение миграций poetry run alembic upgrade head @@ -363,156 +263,57 @@ poetry run pytest ## Архитектура ```mermaid -flowchart TB - subgraph Внешние сервисы - DB[(PostgreSQL)] - Redis[(Redis)] - Deribit[Deribit API] +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 Приложение - API[FastAPI Приложение] - Celery[Celery Воркеры] - Beat[Celery Beat] + subgraph Infrastructure + Redis[(Redis)]:::db + end + + subgraph Application + API[FastAPI App]:::app - subgraph Сервисы - PS[Сервис цен] - Rep[Репозиторий] - Client[Клиент Deribit] + subgraph Logic + direction TB + PS[Price Service]:::app + Rep[Repository]:::app + Client[Deribit Client]:::app end - subgraph Задачи - PC[Сбор цен] - HC[Проверка здоровья] + subgraph Workers + direction TB + Beat[Celery Beat]:::task + Celery[Celery Worker]:::task + PC[Tasks: Price Collection]:::task end + + HC(Health Check):::task end - Client --> Deribit API --> PS PS --> Rep - Rep --> DB + Rep ==> DB + Beat --> Redis + Redis --> Celery Celery --> PC PC --> Client PC --> Rep - Beat --> Redis - Redis --> Celery - HC --> |Мониторинг| Client - HC --> |Мониторинг| DB - HC --> |Мониторинг| Redis -``` + Client -- HTTP/WS --> Deribit -## Обзор компонентов - -├── .github/ # -│ └── workflows/ # -│ └── ci.yml # -├── alembic/ # -│ ├── versions/ # -│ │ └── 2026/ # -│ │ └── 01/ # -│ │ └── 25_2149_52_19cfef6b2cba_create_price_ticks_table.py # -│ ├── README # -│ ├── env.py # -│ └── script.py.mako # -├── app/ # -│ ├── api/ # -│ │ ├── v1/ # -│ │ │ ├── endpoints/ # -│ │ │ │ ├── __init__.py # -│ │ │ │ └── prices.py # -│ │ │ ├── __init__.py # -│ │ │ └── schemas.py # -│ │ ├── __init__.py # -│ │ ├── exceptions.py # -│ │ └── routes.py # -│ ├── clients/ # -│ │ ├── __init__.py # -│ │ ├── deribit.py # -│ │ └── exceptions.py # -│ ├── core/ # -│ │ ├── __init__.py # -│ │ ├── config.py # -│ │ └── logger.py # -│ ├── database/ # -│ │ ├── __init__.py # -│ │ ├── base.py # -│ │ ├── manager.py # -│ │ ├── models.py # -│ │ └── repository.py # -│ ├── dependencies/ # -│ │ ├── __init__.py # -│ │ ├── clients.py # -│ │ ├── database.py # -│ │ └── services.py # -│ ├── frontend/ # -│ │ ├── static/ # -│ │ │ └── css/ # -│ │ │ └── style.css # -│ │ ├── templates/ # -│ │ │ └── index.html # -│ │ ├── __init__.py # -│ │ └── routes.py # -│ ├── services/ # -│ │ ├── __init__.py # -│ │ └── price_service.py # -│ ├── tasks/ # -│ │ ├── __init__.py # -│ │ ├── celery_application.py # -│ │ ├── dependencies.py # -│ │ └── price_collection.py # -│ ├── __init__.py # -│ └── main.py # -├── tests/ # -│ ├── test_api/ # -│ │ ├── __init__.py # -│ │ └── test_endpoints.py # -│ ├── test_clients/ # -│ │ ├── __init__.py # -│ │ └── test_deribit.py # -│ ├── test_database/ # -│ │ ├── __init__.py # -│ │ └── test_repository.py # -│ ├── test_services/ # -│ │ ├── __init__.py # -│ │ └── test_price_service.py # -│ ├── test_tasks/ # -│ │ ├── __init__.py # -│ │ └── test_celery.py # -│ ├── __init__.py # -│ └── conftest.py # -├── .bandit.yml # -├── .dockerignore # -├── .env.example # -├── .gitignore # -├── .gitlab-ci.yml # -├── .pre-commit-config.yaml # -├── .secrets.baseline # -├── Dockerfile # -├── LICENSE # -├── README.md # -├── alembic.ini # -├── docker-compose.yml # -├── poetry.lock # -├── pyproject.toml # -└── test.db # - -### Основные компоненты -- **FastAPI Приложение**: Основной веб-сервер для обработки HTTP-запросов -- **Celery Воркеры**: Распределенная обработка задач -- **PostgreSQL**: Основное хранилище данных -- **Redis**: Брокер сообщений и хранилище результатов задач - -### Сервисный слой -- **Сервис цен**: Реализация бизнес-логики -- **Репозиторий**: Абстракция доступа к данным -- **Клиент Deribit**: Интеграция с внешним API - -### Обработка задач -- **Сбор цен**: Планируемый сбор цен -- **Проверка здоровья**: Мониторинг системы -- **Celery Beat**: Планировщик задач + HC -.-> |ping| Client + HC -.-> |ping| DB + HC -.-> |ping| Redis +``` ## Архитектурные решения @@ -547,7 +348,6 @@ flowchart TB ### Тестирование - **Комплексный набор тестов**: - Модульные тесты с pytest - - Интеграционные тесты - Поддержка асинхронного тестирования - Фреймворки для мокирования внешних сервисов - CI/CD pipeline с GitHub Actions @@ -555,8 +355,7 @@ flowchart TB ### Мониторинг - **Логирование и метрики**: - Структурированное логирование - - Endpoint'ы проверки здоровья - - Мониторинг производительности + - Health check - Отслеживание ошибок ## Лицензия @@ -564,5 +363,3 @@ flowchart TB Этот проект лицензирован под MIT License - см. файл [LICENSE](LICENSE) для подробностей. - - diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css index f22413b..2b4a93c 100644 --- a/app/frontend/static/css/style.css +++ b/app/frontend/static/css/style.css @@ -6,10 +6,12 @@ --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; + box-sizing: border-box; margin: 0; padding: 0; } @@ -25,6 +27,7 @@ body { linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); background-size: 30px 30px; position: relative; + padding: 20px; } /* CRT Scanline Effect */ @@ -39,59 +42,83 @@ body::before { background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%); background-size: 100% 4px; - z-index: 2; + z-index: 1; pointer-events: none; } .container { - max-width: 1200px; + max-width: 1400px; margin: 0 auto; - padding: 20px; position: relative; - z-index: 3; + z-index: 2; + display: flex; + flex-direction: column; + gap: 30px; } /* Header */ header { - border-bottom: 2px solid var(--neon-pink); - padding-bottom: 20px; - margin-bottom: 40px; + border: 2px solid var(--neon-pink); + padding: 25px 35px; + background: var(--panel-bg); display: flex; justify-content: space-between; - align-items: flex-end; - box-shadow: 0 0 15px var(--neon-pink); + 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: 2.5rem; + 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: 5px 15px; + padding: 12px 25px; color: var(--neon-blue); - box-shadow: 0 0 5px 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(300px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 30px; - margin-bottom: 40px; + margin-bottom: 10px; } .card { - background: rgba(1, 1, 43, 0.9); - border: 1px solid var(--neon-purple); - padding: 20px; + background: var(--card-bg); + border: 2px solid var(--neon-purple); + padding: 25px; position: relative; - box-shadow: 0 0 10px rgba(211, 4, 204, 0.3); + 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 { @@ -101,77 +128,281 @@ h1 { left: 0; width: 100%; height: 100%; - box-shadow: inset 0 0 20px rgba(211, 4, 204, 0.1); + 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-bottom: 15px; - border-bottom: 1px dashed var(--neon-blue); - padding-bottom: 5px; + margin: 0; + border-bottom: 2px dashed var(--neon-blue); + padding-bottom: 10px; + font-size: 1.3rem; } .price-display { - font-size: 2.5rem; + font-size: clamp(2rem, 5vw, 3rem); color: #fff; - text-shadow: 0 0 10px #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.8rem; + font-size: 0.85rem; color: var(--neon-blue); + opacity: 0.9; +} + +.timestamp { + font-size: 0.8rem; + color: var(--neon-pink); opacity: 0.8; } -/* Data Table */ -.data-panel { - border: 1px solid var(--neon-blue); - background: rgba(0, 0, 0, 0.8); - padding: 20px; +/* 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; } -.controls { - margin-bottom: 20px; +.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; } -select, -button { - background: transparent; +.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 { + flex: 1; + 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: 10px 20px; + padding: 12px 20px; font-family: var(--terminal-font); - cursor: pointer; + 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; } -button:hover { +.btn-primary { background: var(--neon-blue); color: var(--dark-bg); - box-shadow: 0 0 15px var(--neon-blue); + 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; - margin-top: 10px; + min-width: 800px; } th { text-align: left; color: var(--neon-pink); border-bottom: 2px solid var(--neon-purple); - padding: 10px; + padding: 15px; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + background: rgba(0, 0, 30, 0.8); } td { - padding: 10px; + padding: 15px; border-bottom: 1px solid rgba(5, 217, 232, 0.3); color: var(--text-main); + font-size: 0.9rem; } tr:hover td { @@ -179,28 +410,189 @@ tr:hover td { 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.4; + 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 index e259f02..b2f87c9 100644 --- a/app/frontend/templates/index.html +++ b/app/frontend/templates/index.html @@ -16,9 +16,9 @@
-
+

{{ project_name }}

-
+
SYS.VER: {{ version }} // CONNECTED
@@ -33,43 +33,184 @@

{{ project_name }}

>> BTC_USD

LOADING...
INDEX_PRICE // LIVE
+
--:--:--

>> ETH_USD

LOADING...
INDEX_PRICE // LIVE
+
--:--:--
- -
+ +

- >> ARCHIVE_ACCESS + >> API_INTERFACE

-
- - +
+ +
+ + +
+ + +
+ +
+ + +
+ + + + + + + + + + + + +
+ + +
+ + + +
+ + +
+ +
/api/v1/prices/?ticker=btc_usd&limit=10
+
-
- - - - - - - - - - - - -
TIMESTAMP (UTC)TICKERPRICE (USD)ID
+ +
+
+

QUERY_RESULTS

+
+ STATUS: PENDING +
+
+ + +
+ + + + + + + + + + + + +
TIMESTAMP (UTC)TICKERPRICE (USD)RECORD_ID
+
+ + + + + +
@@ -77,86 +218,329 @@

From f14da41ebc060c535297ad67a3dd61a1276f2434 Mon Sep 17 00:00:00 2001 From: script-logic Date: Fri, 30 Jan 2026 15:26:53 +0300 Subject: [PATCH 9/9] feat: MVP --- app/frontend/static/css/style.css | 1 - app/frontend/templates/index.html | 38 ++++--------------------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/app/frontend/static/css/style.css b/app/frontend/static/css/style.css index 2b4a93c..d85c4a2 100644 --- a/app/frontend/static/css/style.css +++ b/app/frontend/static/css/style.css @@ -228,7 +228,6 @@ h1 { } .date-inputs > div, .pagination-inputs > div { - flex: 1; min-width: 200px; } diff --git a/app/frontend/templates/index.html b/app/frontend/templates/index.html index b2f87c9..02e341d 100644 --- a/app/frontend/templates/index.html +++ b/app/frontend/templates/index.html @@ -22,9 +22,6 @@

{{ project_name }}

SYS.VER: {{ version }} // CONNECTED

-
- ● SYSTEM ONLINE -
@@ -146,6 +143,7 @@

QUERY_RESULTS

TIMESTAMP (UTC) + TIMESTAMP (UNIX) TICKER PRICE (USD) RECORD_ID @@ -218,7 +216,6 @@

SINGLE_PRICE_RECORD