From 70f95632f9bb754de1a40f8137f364e5bbcf6c43 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 29 Sep 2025 19:59:49 -0400 Subject: [PATCH 1/9] feat: add backend infrastructure foundation Creates the base infrastructure layer for BRC Analytics backend: - FastAPI application with health and cache endpoints - Redis caching service with TTL management - Docker Compose setup (backend + redis + nginx) - nginx reverse proxy configuration - uv for dependency management - Ruff formatting for Python code - E2E health check tests This branch serves as the foundation for feature branches to build upon. --- .gitignore | 5 + backend/.dockerignore | 35 +++ backend/.env.example | 14 ++ backend/Dockerfile | 46 ++++ backend/README.md | 99 ++++++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/v1/__init__.py | 1 + backend/app/api/v1/cache.py | 31 +++ backend/app/api/v1/health.py | 16 ++ backend/app/core/__init__.py | 1 + backend/app/core/cache.py | 118 ++++++++++ backend/app/core/config.py | 34 +++ backend/app/core/dependencies.py | 16 ++ backend/app/main.py | 31 +++ backend/app/services/__init__.py | 1 + backend/pyproject.toml | 32 +++ backend/ruff.toml | 12 + backend/uv.lock | 372 +++++++++++++++++++++++++++++++ docker-compose.yml | 58 +++++ nginx.conf | 66 ++++++ playwright.config.ts | 39 ++++ pyproject.toml | 1 + scripts/format-python.sh | 4 +- tests/e2e/03-api-health.spec.ts | 45 ++++ 25 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/cache.py create mode 100644 backend/app/api/v1/health.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/cache.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/dependencies.py create mode 100644 backend/app/main.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/pyproject.toml create mode 100644 backend/ruff.toml create mode 100644 backend/uv.lock create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 playwright.config.ts create mode 100644 tests/e2e/03-api-health.spec.ts diff --git a/.gitignore b/.gitignore index fa70ebdbd..1c26dcfcf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ npm-debug.log* /.env*.local /.env.development /.env.production +backend/.env # typescript *.tsbuildinfo @@ -32,6 +33,10 @@ npm-debug.log* # Build Dir /out +# Playwright test artifacts +/test-results +/tests/screenshots + # python venv __pycache__ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..34842948c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,35 @@ +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache +.mypy_cache + +# Virtual environments +.env +.venv +env/ +venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Documentation +*.md +docs/ + +# Tests +tests/ \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..f786a7588 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Redis Configuration +REDIS_URL=redis://redis:6379/0 + +# Database Configuration (for future use) +DATABASE_URL=postgresql://user:pass@localhost/dbname + +# Application Configuration +CORS_ORIGINS=http://localhost:3000,http://localhost +LOG_LEVEL=INFO +ENVIRONMENT=development + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=60 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..def55cdcf --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,46 @@ +# Multi-stage build for uv-based Python application +FROM python:3.12-slim as builder + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies into .venv +RUN uv sync --frozen --no-install-project + +# Production stage +FROM python:3.12-slim as runtime + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy uv for runtime +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app + +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Add venv to PATH +ENV PATH="/app/.venv/bin:$PATH" + +# Copy application code +COPY . . + +# Change ownership +RUN chown -R app:app /app +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 000000000..6f5a8d441 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,99 @@ +# BRC Analytics Backend + +FastAPI backend infrastructure for BRC Analytics. + +## Features + +- FastAPI REST API +- Redis caching with TTL support +- Health check endpoints +- Docker deployment with nginx reverse proxy +- uv for dependency management + +## Quick Start + +### Development (Local) + +```bash +cd backend +uv sync +uv run uvicorn app.main:app --reload +``` + +API documentation: http://localhost:8000/api/docs + +### Production (Docker) + +```bash +# Create environment file +cp backend/.env.example backend/.env +# Edit backend/.env if needed (defaults work for local development) + +# Start all services (nginx + backend + redis) +docker compose up -d + +# Check service health +curl http://localhost/api/v1/health + +# View logs +docker compose logs -f backend + +# Rebuild after code changes +docker compose up -d --build + +# Stop all services +docker compose down +``` + +Services: + +- nginx: http://localhost (reverse proxy) +- backend API: http://localhost:8000 (direct access) +- API docs: http://localhost/api/docs +- redis: localhost:6379 + +## API Endpoints + +### Health & Monitoring + +- `GET /api/v1/health` - Overall service health status +- `GET /api/v1/cache/health` - Redis cache connectivity check + +### Documentation + +- `GET /api/docs` - Interactive Swagger UI +- `GET /api/redoc` - ReDoc API documentation + +## Configuration + +Environment variables (see `.env.example`): + +```bash +# Redis +REDIS_URL=redis://localhost:6379/0 + +# Application +CORS_ORIGINS=http://localhost:3000,http://localhost +LOG_LEVEL=INFO +``` + +## Testing + +```bash +# Run e2e tests +npm run test:e2e + +# Or with Playwright directly +npx playwright test tests/e2e/03-api-health.spec.ts +``` + +## Architecture + +``` +nginx (port 80) + ├── /api/* → FastAPI backend (port 8000) + └── /* → Next.js static files + +FastAPI backend + └── Redis cache (port 6379) +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..4c10750f2 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# BRC Analytics Backend diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 000000000..28b07eff6 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 000000000..6c2f33cb2 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 package diff --git a/backend/app/api/v1/cache.py b/backend/app/api/v1/cache.py new file mode 100644 index 000000000..82d8a30c7 --- /dev/null +++ b/backend/app/api/v1/cache.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.core.cache import CacheService +from app.core.dependencies import get_cache_service + +router = APIRouter() + + +@router.get("/health") +async def cache_health(cache: CacheService = Depends(get_cache_service)): + """Check if cache service is healthy""" + try: + # Try to set and get a test value + test_key = "health_check" + test_value = "ok" + + await cache.set(test_key, test_value, ttl=60) + result = await cache.get(test_key) + await cache.delete(test_key) + + if result == test_value: + return {"status": "healthy", "cache": "connected"} + else: + raise HTTPException( + status_code=503, detail="Cache service not responding correctly" + ) + + except Exception as e: + raise HTTPException( + status_code=503, detail=f"Cache service unhealthy: {str(e)}" + ) diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 000000000..53bfd9ea5 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + """Health check endpoint for monitoring system status""" + return { + "status": "healthy", + "version": "1.0.0", + "timestamp": datetime.utcnow().isoformat(), + "service": "BRC Analytics API", + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 000000000..d61a2551c --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core package diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py new file mode 100644 index 000000000..bcec835b5 --- /dev/null +++ b/backend/app/core/cache.py @@ -0,0 +1,118 @@ +import hashlib +import json +import logging +from datetime import timedelta +from typing import Any, Dict, Optional + +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +class CacheService: + """Redis-based cache service with TTL support and key management""" + + def __init__(self, redis_url: str): + self.redis = redis.from_url(redis_url, decode_responses=True) + + async def get(self, key: str) -> Optional[Any]: + """Get a value from cache by key""" + try: + value = await self.redis.get(key) + if value: + return json.loads(value) + return None + except (redis.RedisError, json.JSONDecodeError) as e: + logger.error(f"Cache get error for key {key}: {e}") + return None + + async def set(self, key: str, value: Any, ttl: int = 3600) -> bool: + """Set a value in cache with TTL (time to live) in seconds""" + try: + serialized_value = json.dumps(value, default=str) + await self.redis.setex(key, ttl, serialized_value) + return True + except (redis.RedisError, TypeError) as e: + logger.error(f"Cache set error for key {key}: {e}") + return False + + async def delete(self, key: str) -> bool: + """Delete a key from cache""" + try: + result = await self.redis.delete(key) + return result > 0 + except redis.RedisError as e: + logger.error(f"Cache delete error for key {key}: {e}") + return False + + async def exists(self, key: str) -> bool: + """Check if key exists in cache""" + try: + return await self.redis.exists(key) > 0 + except redis.RedisError as e: + logger.error(f"Cache exists error for key {key}: {e}") + return False + + async def get_ttl(self, key: str) -> int: + """Get remaining TTL for a key (-1 if no TTL, -2 if key doesn't exist)""" + try: + return await self.redis.ttl(key) + except redis.RedisError as e: + logger.error(f"Cache TTL error for key {key}: {e}") + return -2 + + async def clear_pattern(self, pattern: str) -> int: + """Clear all keys matching a pattern""" + try: + keys = await self.redis.keys(pattern) + if keys: + return await self.redis.delete(*keys) + return 0 + except redis.RedisError as e: + logger.error(f"Cache clear pattern error for {pattern}: {e}") + return 0 + + async def get_stats(self) -> Dict[str, Any]: + """Get cache statistics""" + try: + info = await self.redis.info() + return { + "hits": info.get("keyspace_hits", 0), + "misses": info.get("keyspace_misses", 0), + "hit_rate": self._calculate_hit_rate(info), + "memory_used": info.get("used_memory_human", "0B"), + "memory_used_bytes": info.get("used_memory", 0), + "keys_count": await self.redis.dbsize(), + "connected_clients": info.get("connected_clients", 0), + } + except redis.RedisError as e: + logger.error(f"Cache stats error: {e}") + return {} + + def _calculate_hit_rate(self, info: Dict) -> float: + """Calculate cache hit rate from Redis info""" + hits = info.get("keyspace_hits", 0) + misses = info.get("keyspace_misses", 0) + total = hits + misses + return (hits / total) if total > 0 else 0.0 + + def make_key(self, prefix: str, params: Dict[str, Any]) -> str: + """Generate a cache key from prefix and parameters""" + # Sort parameters for consistent keys + param_str = json.dumps(params, sort_keys=True, default=str) + hash_val = hashlib.md5(param_str.encode()).hexdigest()[:16] + return f"{prefix}:{hash_val}" + + async def close(self): + """Close Redis connection""" + await self.redis.close() + + +# Cache TTL constants (in seconds) +class CacheTTL: + FIVE_MINUTES = 300 + ONE_HOUR = 3600 + SIX_HOURS = 21600 + ONE_DAY = 86400 + ONE_WEEK = 604800 + THIRTY_DAYS = 2592000 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 000000000..eaa875b06 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,34 @@ +import os +from functools import lru_cache +from typing import List + + +class Settings: + """Application settings loaded from environment variables""" + + # Redis settings + REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") + + # Database settings (for future use) + DATABASE_URL: str = os.getenv("DATABASE_URL", "") + + # CORS settings + CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:3000").split( + "," + ) + + # Logging + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + + # Environment + ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development") + + # Rate limiting + RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) + RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) # seconds + + +@lru_cache() +def get_settings() -> Settings: + """Get cached settings instance""" + return Settings() diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 000000000..b49924b6b --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,16 @@ +from functools import lru_cache + +from app.core.cache import CacheService +from app.core.config import get_settings + +# Global cache service instance +_cache_service = None + + +async def get_cache_service() -> CacheService: + """Dependency to get cache service instance""" + global _cache_service + if _cache_service is None: + settings = get_settings() + _cache_service = CacheService(settings.REDIS_URL) + return _cache_service diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..0beec7e0f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import cache, health +from app.core.config import get_settings + +settings = get_settings() + +app = FastAPI( + title="BRC Analytics API", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health.router, prefix="/api/v1", tags=["health"]) +app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"]) + + +@app.get("/") +async def root(): + return {"message": "BRC Analytics API", "version": "1.0.0"} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 000000000..a70b3029a --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 000000000..c9474e351 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "brc-analytics-backend" +version = "0.1.0" +description = "FastAPI backend infrastructure for BRC Analytics" +authors = [ + {name = "BRC Team"} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116.1", + "uvicorn>=0.35.0", + "redis>=6.4.0", + "httpx>=0.28.1", + "pydantic>=2.11.7", + "python-dotenv>=1.1.1" +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", + "ruff>=0.13.0" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 000000000..bf02cb1b1 --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,12 @@ +# Ruff configuration for backend +# This prevents ruff from trying to parse poetry's pyproject.toml +target-version = "py312" +line-length = 88 + +[format] +quote-style = "double" +indent-style = "space" + +[lint] +select = ["E", "F", "I", "B"] +fixable = ["ALL"] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 000000000..a96f5410f --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,372 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "brc-analytics-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "redis" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.1.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.0" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.118.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/3c/2b9345a6504e4055eaa490e0b41c10e338ad61d9aeaae41d97807873cdf2/fastapi-0.118.0.tar.gz", hash = "sha256:5e81654d98c4d2f53790a7d32d25a7353b30c81441be7d0958a26b5d761fa1c8", size = 310536, upload-time = "2025-09-29T03:37:23.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" }, + { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" }, + { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..5998b6b52 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ./out:/usr/share/nginx/html + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + env_file: + - ./backend/.env + environment: + # Only override what needs Docker networking + REDIS_URL: redis://redis:6379/0 + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + redis_data: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 000000000..d4371d099 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,66 @@ +upstream backend { + server backend:8000; +} + +server { + listen 80; + server_name localhost; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + application/json + application/javascript + text/css + text/javascript + text/plain + text/xml; + + # Security headers + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + + # API routes - proxy to FastAPI backend + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + proxy_send_timeout 300; + } + + # Static files - serve Next.js build output + location / { + root /usr/share/nginx/html; + try_files $uri $uri.html $uri/ /index.html; + + # Cache static assets + location ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Cache HTML files for a shorter period + location ~* \.html$ { + expires 1h; + add_header Cache-Control "public"; + } + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..a6b16332d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for BRC Analytics tests + */ +export default defineConfig({ + forbidOnly: !!process.env.CI, + fullyParallel: false, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + reporter: "html", + retries: process.env.CI ? 2 : 0, + testDir: "./tests/e2e", + use: { + baseURL: "http://localhost:3000", + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "retain-on-failure", + }, + webServer: [ + { + command: "npm run dev", + reuseExistingServer: true, + timeout: 120 * 1000, + url: "http://localhost:3000", + }, + { + command: "docker-compose up", + reuseExistingServer: true, + timeout: 120 * 1000, + url: "http://localhost:8000/api/v1/health", + }, + ], + workers: 1, +}); diff --git a/pyproject.toml b/pyproject.toml index d2cd8cad6..e69f26a1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ target-version = "py38" line-length = 88 indent-width = 4 +extend-exclude = ["backend/pyproject.toml", "backend/poetry.lock"] [tool.ruff.format] quote-style = "double" diff --git a/scripts/format-python.sh b/scripts/format-python.sh index 960f7ed55..93f7400bb 100755 --- a/scripts/format-python.sh +++ b/scripts/format-python.sh @@ -2,11 +2,11 @@ # Format Python files using Ruff (Rust-based formatter) echo "Formatting Python files with Ruff..." -ruff format catalog/ +ruff format catalog/ backend/ # Sort imports with Ruff echo "Sorting imports with Ruff..." -ruff check --select I --fix catalog/ +ruff check --select I --fix catalog/ backend/ # Exit with Ruff's status code exit $? \ No newline at end of file diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts new file mode 100644 index 000000000..614c6743c --- /dev/null +++ b/tests/e2e/03-api-health.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +test.describe("BRC Analytics - API Infrastructure", () => { + test("backend API should be healthy", async ({ request }) => { + // Check backend health endpoint + const response = await request.get("http://localhost:8000/api/v1/health"); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + expect(health.status).toBe("healthy"); + expect(health.service).toBe("BRC Analytics API"); + console.log("Backend health:", health); + }); + + test("cache health check should work", async ({ request }) => { + // Check cache health endpoint + const response = await request.get( + "http://localhost:8000/api/v1/cache/health" + ); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + expect(health.status).toBe("healthy"); + expect(health.cache).toBe("connected"); + console.log("Cache health:", health); + }); + + test("API documentation should be accessible", async ({ page }) => { + // Navigate to API docs + await page.goto("http://localhost:8000/api/docs"); + + // Check that Swagger UI loaded + await expect(page.locator(".swagger-ui")).toBeVisible({ timeout: 10000 }); + + // Check for API title + const title = page.locator(".title"); + await expect(title).toContainText("BRC Analytics API"); + + // Screenshot the API docs + await page.screenshot({ + fullPage: true, + path: "tests/screenshots/api-docs.png", + }); + }); +}); From 91cf035e8bfe925e70709d0399eca391926e70fb Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 5 Oct 2025 12:40:50 -0400 Subject: [PATCH 2/9] feat: add version endpoint for API metadata Adds GET /api/v1/version endpoint that returns: - API version (from APP_VERSION env var) - Environment (development/production) - Service name This provides a simple way for the frontend to display version info and demonstrates the backend infrastructure is working. --- backend/README.md | 1 + backend/app/api/v1/version.py | 16 ++++++++++++++++ backend/app/core/config.py | 3 +++ backend/app/main.py | 3 ++- tests/e2e/03-api-health.spec.ts | 12 ++++++++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/v1/version.py diff --git a/backend/README.md b/backend/README.md index 6f5a8d441..59dd36564 100644 --- a/backend/README.md +++ b/backend/README.md @@ -58,6 +58,7 @@ Services: - `GET /api/v1/health` - Overall service health status - `GET /api/v1/cache/health` - Redis cache connectivity check +- `GET /api/v1/version` - API version and environment information ### Documentation diff --git a/backend/app/api/v1/version.py b/backend/app/api/v1/version.py new file mode 100644 index 000000000..45845f339 --- /dev/null +++ b/backend/app/api/v1/version.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.core.config import get_settings + +router = APIRouter() +settings = get_settings() + + +@router.get("") +async def get_version(): + """Get API version and build information""" + return { + "version": settings.APP_VERSION, + "environment": settings.ENVIRONMENT, + "service": "BRC Analytics API", + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index eaa875b06..97a07dfcb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,6 +6,9 @@ class Settings: """Application settings loaded from environment variables""" + # Application + APP_VERSION: str = os.getenv("APP_VERSION", "1.0.0") + # Redis settings REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") diff --git a/backend/app/main.py b/backend/app/main.py index 0beec7e0f..c5639b909 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1 import cache, health +from app.api.v1 import cache, health, version from app.core.config import get_settings settings = get_settings() @@ -24,6 +24,7 @@ # Include routers app.include_router(health.router, prefix="/api/v1", tags=["health"]) app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"]) +app.include_router(version.router, prefix="/api/v1/version", tags=["version"]) @app.get("/") diff --git a/tests/e2e/03-api-health.spec.ts b/tests/e2e/03-api-health.spec.ts index 614c6743c..d226146fc 100644 --- a/tests/e2e/03-api-health.spec.ts +++ b/tests/e2e/03-api-health.spec.ts @@ -25,6 +25,18 @@ test.describe("BRC Analytics - API Infrastructure", () => { console.log("Cache health:", health); }); + test("version endpoint should return version info", async ({ request }) => { + // Check version endpoint + const response = await request.get("http://localhost:8000/api/v1/version"); + expect(response.ok()).toBeTruthy(); + + const version = await response.json(); + expect(version.version).toBeTruthy(); + expect(version.environment).toBeTruthy(); + expect(version.service).toBe("BRC Analytics API"); + console.log("API version:", version); + }); + test("API documentation should be accessible", async ({ page }) => { // Navigate to API docs await page.goto("http://localhost:8000/api/docs"); From 0a9362896c1d5b46c1d442b986f8dae3ec8b8af2 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sun, 5 Oct 2025 13:28:25 -0400 Subject: [PATCH 3/9] feat: sync backend version from package.json Use APP_VERSION build arg to inject version from package.json into backend container. Adds docker-build.sh script to automate this. All endpoints now use settings.APP_VERSION instead of hardcoded values. --- backend/Dockerfile | 4 ++++ backend/README.md | 3 +++ backend/app/api/v1/health.py | 5 ++++- backend/app/core/config.py | 2 +- backend/app/main.py | 4 ++-- docker-compose.yml | 2 ++ scripts/docker-build.sh | 8 ++++++++ 7 files changed, 24 insertions(+), 4 deletions(-) create mode 100755 scripts/docker-build.sh diff --git a/backend/Dockerfile b/backend/Dockerfile index def55cdcf..2ac07f53c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,10 @@ RUN uv sync --frozen --no-install-project # Production stage FROM python:3.12-slim as runtime +# Accept version as build argument +ARG APP_VERSION=0.15.0 +ENV APP_VERSION=${APP_VERSION} + # Install system dependencies RUN apt-get update && apt-get install -y \ curl \ diff --git a/backend/README.md b/backend/README.md index 59dd36564..0d8cb8ade 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,6 +29,9 @@ API documentation: http://localhost:8000/api/docs cp backend/.env.example backend/.env # Edit backend/.env if needed (defaults work for local development) +# Build with version from package.json +./scripts/docker-build.sh + # Start all services (nginx + backend + redis) docker compose up -d diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py index 53bfd9ea5..47ee9ecaa 100644 --- a/backend/app/api/v1/health.py +++ b/backend/app/api/v1/health.py @@ -2,7 +2,10 @@ from fastapi import APIRouter +from app.core.config import get_settings + router = APIRouter() +settings = get_settings() @router.get("/health") @@ -10,7 +13,7 @@ async def health_check(): """Health check endpoint for monitoring system status""" return { "status": "healthy", - "version": "1.0.0", + "version": settings.APP_VERSION, "timestamp": datetime.utcnow().isoformat(), "service": "BRC Analytics API", } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 97a07dfcb..ef69175e9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ class Settings: """Application settings loaded from environment variables""" # Application - APP_VERSION: str = os.getenv("APP_VERSION", "1.0.0") + APP_VERSION: str = os.getenv("APP_VERSION", "0.15.0") # Redis settings REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") diff --git a/backend/app/main.py b/backend/app/main.py index c5639b909..3d8521463 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ app = FastAPI( title="BRC Analytics API", - version="1.0.0", + version=settings.APP_VERSION, docs_url="/api/docs", redoc_url="/api/redoc", ) @@ -29,4 +29,4 @@ @app.get("/") async def root(): - return {"message": "BRC Analytics API", "version": "1.0.0"} + return {"message": "BRC Analytics API", "version": settings.APP_VERSION} diff --git a/docker-compose.yml b/docker-compose.yml index 5998b6b52..eb0eced43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: build: context: ./backend dockerfile: Dockerfile + args: + APP_VERSION: ${APP_VERSION:-0.15.0} ports: - "8000:8000" env_file: diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh new file mode 100755 index 000000000..1c00d03df --- /dev/null +++ b/scripts/docker-build.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Build Docker images with version from package.json +VERSION=$(node -p "require('./package.json').version") +export APP_VERSION=$VERSION + +echo "Building with version: $VERSION" +docker compose build "$@" From 1a4024b1d344d6556740ca3245b4bc45177711c8 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:14:53 -0400 Subject: [PATCH 4/9] feat: add version display in footer --- .gitignore | 1 + .../Footer/components/Branding/branding.tsx | 2 + .../VersionDisplay/versionDisplay.tsx | 34 +++++++++++++++ app/hooks/useBackendVersion.ts | 24 +++++++++++ site-config/brc-analytics/local/.env | 1 + tests/e2e/04-version-display.spec.ts | 42 +++++++++++++++++++ 6 files changed, 104 insertions(+) create mode 100644 app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx create mode 100644 app/hooks/useBackendVersion.ts create mode 100644 tests/e2e/04-version-display.spec.ts diff --git a/.gitignore b/.gitignore index 1c26dcfcf..1c4efe3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ backend/.env # python venv __pycache__ +playwright-report/ diff --git a/app/components/Layout/components/Footer/components/Branding/branding.tsx b/app/components/Layout/components/Footer/components/Branding/branding.tsx index c6e831789..52e0b0bd5 100644 --- a/app/components/Layout/components/Footer/components/Branding/branding.tsx +++ b/app/components/Layout/components/Footer/components/Branding/branding.tsx @@ -3,6 +3,7 @@ import { ANCHOR_TARGET } from "@databiosphere/findable-ui/lib/components/Links/c import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; import { Brands, FooterText, LargeBrand, SmallBrand } from "./branding.styles"; import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { VersionDisplay } from "./components/VersionDisplay/versionDisplay"; export const Branding = (): JSX.Element => { return ( @@ -54,6 +55,7 @@ export const Branding = (): JSX.Element => { url="https://www.niaid.nih.gov/research/bioinformatics-resource-centers" /> + ); }; diff --git a/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx new file mode 100644 index 000000000..b66059c3c --- /dev/null +++ b/app/components/Layout/components/Footer/components/Branding/components/VersionDisplay/versionDisplay.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; + +const CLIENT_VERSION = process.env.NEXT_PUBLIC_VERSION || "0.15.0"; +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ""; + +export const VersionDisplay = (): JSX.Element => { + const [backendVersion, setBackendVersion] = useState(null); + + useEffect(() => { + if (!BACKEND_URL) { + // No backend URL configured, skip fetching + return; + } + + fetch(`${BACKEND_URL}/api/v1/version`) + .then((res) => res.json()) + .then((data) => setBackendVersion(data.version)) + .catch(() => setBackendVersion(null)); // Gracefully handle backend unavailable + }, []); + + return ( + + Client build: {CLIENT_VERSION} + {backendVersion && ` • Server revision: ${backendVersion}`} + + ); +}; diff --git a/app/hooks/useBackendVersion.ts b/app/hooks/useBackendVersion.ts new file mode 100644 index 000000000..bde3d8879 --- /dev/null +++ b/app/hooks/useBackendVersion.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +interface BackendVersion { + environment: string; + service: string; + version: string; +} + +/** + * Hook to fetch backend version with graceful fallback. + * @returns Backend version string or null if backend is unavailable. + */ +export function useBackendVersion(): string | null { + const [version, setVersion] = useState(null); + + useEffect(() => { + fetch("/api/v1/version") + .then((res) => res.json()) + .then((data: BackendVersion) => setVersion(data.version)) + .catch(() => setVersion(null)); // Gracefully handle backend unavailable + }, []); + + return version; +} diff --git a/site-config/brc-analytics/local/.env b/site-config/brc-analytics/local/.env index 11329b58f..6cf1791ef 100644 --- a/site-config/brc-analytics/local/.env +++ b/site-config/brc-analytics/local/.env @@ -1,3 +1,4 @@ NEXT_PUBLIC_ENA_PROXY_DOMAIN="https://brc-analytics.dev.clevercanary.com" NEXT_PUBLIC_SITE_CONFIG='brc-analytics-local' NEXT_PUBLIC_GALAXY_ENV="TEST" +NEXT_PUBLIC_BACKEND_URL="http://localhost:8000" diff --git a/tests/e2e/04-version-display.spec.ts b/tests/e2e/04-version-display.spec.ts new file mode 100644 index 000000000..6244a3dda --- /dev/null +++ b/tests/e2e/04-version-display.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Version Display", () => { + test("should show client version and backend version in footer", async ({ + page, + }) => { + await page.goto("http://localhost:3000"); + + // Wait for the footer to be visible + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Check for client version (always present) + await expect(footer).toContainText("Client build:"); + + // Check for backend version (should appear after API call) + await expect(footer).toContainText("Server revision:", { timeout: 5000 }); + + // Verify both show 0.15.0 + const versionText = await footer.textContent(); + console.log("Version display:", versionText); + expect(versionText).toMatch(/Client build:.*0\.15\.0/); + expect(versionText).toMatch(/Server revision:.*0\.15\.0/); + }); + + test("should gracefully handle backend unavailable", async ({ page }) => { + // Block the API call to simulate backend unavailable + await page.route("**/api/v1/version", (route) => route.abort("failed")); + + await page.goto("http://localhost:3000"); + + const footer = page.locator("footer"); + await expect(footer).toBeVisible(); + + // Should still show client version + await expect(footer).toContainText("Client build:"); + + // Should NOT show server revision + const versionText = await footer.textContent(); + expect(versionText).not.toContain("Server revision:"); + }); +}); From 5e8f468bb0f925f06d47dada387564d01762e7af Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:18:30 -0400 Subject: [PATCH 5/9] chore: remove unused useBackendVersion hook --- app/hooks/useBackendVersion.ts | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 app/hooks/useBackendVersion.ts diff --git a/app/hooks/useBackendVersion.ts b/app/hooks/useBackendVersion.ts deleted file mode 100644 index bde3d8879..000000000 --- a/app/hooks/useBackendVersion.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; - -interface BackendVersion { - environment: string; - service: string; - version: string; -} - -/** - * Hook to fetch backend version with graceful fallback. - * @returns Backend version string or null if backend is unavailable. - */ -export function useBackendVersion(): string | null { - const [version, setVersion] = useState(null); - - useEffect(() => { - fetch("/api/v1/version") - .then((res) => res.json()) - .then((data: BackendVersion) => setVersion(data.version)) - .catch(() => setVersion(null)); // Gracefully handle backend unavailable - }, []); - - return version; -} From e631bb156f2637ee7c23c164caa181569823030f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 6 Oct 2025 10:45:04 -0400 Subject: [PATCH 6/9] chore: configure Jest to exclude e2e tests --- jest.config.js | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..ce5b87876 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +// Jest was configured in 74d3967f (Sept 2024) but no Jest tests were ever written. +// This config excludes Playwright e2e tests which Jest cannot parse. +module.exports = { + testEnvironment: "jsdom", + testPathIgnorePatterns: ["/node_modules/", "/tests/e2e/"], +}; diff --git a/package.json b/package.json index 2d9e57314..ee2f90671 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "format": "prettier --write . --cache", "format:python": "./scripts/format-python.sh", "prepare": "husky", - "test": "jest --runInBand", + "test": "jest --runInBand --passWithNoTests", "build-brc-db": "esrun catalog/build/ts/build-catalog.ts", "build-ga2-db": "esrun catalog/ga2/build/ts/build-catalog.ts", "build-brc-from-ncbi": "python3 -m catalog.build.py.build_files_from_ncbi", From 69dfb9b716a834ccf913ee5828ef4c3fa329ca16 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 29 Sep 2025 20:02:02 -0400 Subject: [PATCH 7/9] add galaxy job execution features builds on backend-infrastructure to add: - galaxy api integration via bioblend - job submission and monitoring capabilities - workflow execution - job history tracking - results retrieval - frontend galaxy ui (galaxyjob components, galaxyjobview) - react hooks for galaxy functionality - e2e test coverage for galaxy integration - galaxy configuration settings (api url, api key, tool ids) - bioblend dependency for galaxy communication this feature can be independently reviewed and tested --- .../GalaxyJobForm/galaxyJobForm.styles.ts | 38 ++ .../GalaxyJob/GalaxyJobForm/galaxyJobForm.tsx | 251 +++++++++ .../GalaxyJobHistory/galaxyJobHistory.tsx | 240 +++++++++ .../GalaxyJobResults/galaxyJobResults.tsx | 262 ++++++++++ .../GalaxyJobStatus/galaxyJobStatus.tsx | 420 +++++++++++++++ app/components/GalaxyJob/galaxyJob.tsx | 32 ++ app/hooks/useGalaxyJob.ts | 494 ++++++++++++++++++ .../GalaxyJobView/galaxyJobView.styles.ts | 18 + app/views/GalaxyJobView/galaxyJobView.tsx | 26 + backend/.env.example | 8 + backend/app/api/v1/galaxy.py | 253 +++++++++ backend/app/core/config.py | 8 + backend/app/main.py | 3 +- backend/app/models/__init__.py | 1 + backend/app/models/galaxy.py | 123 +++++ backend/app/services/galaxy_service.py | 340 ++++++++++++ backend/pyproject.toml | 7 +- pages/galaxy-test/index.tsx | 22 + tests/e2e/09-galaxy-integration.spec.ts | 337 ++++++++++++ 19 files changed, 2879 insertions(+), 4 deletions(-) create mode 100644 app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.styles.ts create mode 100644 app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.tsx create mode 100644 app/components/GalaxyJob/GalaxyJobHistory/galaxyJobHistory.tsx create mode 100644 app/components/GalaxyJob/GalaxyJobResults/galaxyJobResults.tsx create mode 100644 app/components/GalaxyJob/GalaxyJobStatus/galaxyJobStatus.tsx create mode 100644 app/components/GalaxyJob/galaxyJob.tsx create mode 100644 app/hooks/useGalaxyJob.ts create mode 100644 app/views/GalaxyJobView/galaxyJobView.styles.ts create mode 100644 app/views/GalaxyJobView/galaxyJobView.tsx create mode 100644 backend/app/api/v1/galaxy.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/galaxy.py create mode 100644 backend/app/services/galaxy_service.py create mode 100644 pages/galaxy-test/index.tsx create mode 100644 tests/e2e/09-galaxy-integration.spec.ts diff --git a/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.styles.ts b/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.styles.ts new file mode 100644 index 000000000..9b09be9ee --- /dev/null +++ b/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.styles.ts @@ -0,0 +1,38 @@ +import styled from "@emotion/styled"; + +export const FormContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + max-width: 800px; + margin: 0 auto; +`; + +export const FormSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const InputSection = styled.div` + display: flex; + gap: 16px; + align-items: center; + + @media (max-width: 600px) { + flex-direction: column; + align-items: stretch; + } +`; + +export const ControlSection = styled.div` + display: flex; + gap: 16px; + align-items: center; + margin-top: 16px; + + @media (max-width: 600px) { + flex-direction: column; + align-items: stretch; + } +`; diff --git a/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.tsx b/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.tsx new file mode 100644 index 000000000..aedae7c57 --- /dev/null +++ b/app/components/GalaxyJob/GalaxyJobForm/galaxyJobForm.tsx @@ -0,0 +1,251 @@ +import { useState } from "react"; +import { + Button, + TextField, + Typography, + Card, + CardContent, + Alert, + Chip, + Box, + Divider, +} from "@mui/material"; +import { PlayArrow, Refresh, Stop } from "@mui/icons-material"; +import { useGalaxyJob } from "../../../hooks/useGalaxyJob"; +import { GalaxyJobStatus } from "../GalaxyJobStatus/galaxyJobStatus"; +import { + FormContainer, + FormSection, + InputSection, + ControlSection, +} from "./galaxyJobForm.styles"; + +interface GalaxyJobFormProps { + galaxyJobHook: ReturnType; +} + +// Sample data for testing +const SAMPLE_DATA = `Sample_ID Gene Position Ref Alt Depth Quality Effect +S001 ACE2 23403 A G 156 99 missense_variant +S001 ORF1ab 14408 C T 203 99 synonymous_variant +S002 Spike 23012 G A 178 98 missense_variant +S002 Spike 23063 A T 145 97 missense_variant +S003 ORF8 28144 T C 234 99 synonymous_variant +S003 N 28881 G A 189 99 missense_variant +S004 Spike 22995 C A 167 98 missense_variant +S004 ORF3a 25563 G T 201 99 missense_variant +S005 M 26530 A G 178 97 synonymous_variant +S005 E 26256 C T 156 96 synonymous_variant`; + +export const GalaxyJobForm = ({ + galaxyJobHook, +}: GalaxyJobFormProps): JSX.Element => { + const [tabularData, setTabularData] = useState(SAMPLE_DATA); + const [numLines, setNumLines] = useState(3); + const [filename, setFilename] = useState("variant_calls"); + + const { + // Loading and submission state + isPolling, + isSubmitting, + + // Job state + jobId, + jobResults, + jobStatus, + + // Error states + pollingError, + // Actions + reset, + resultsError, + stopPolling, + submissionError, + submitJob, + } = galaxyJobHook; + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + + if (!tabularData.trim()) { + return; + } + + await submitJob({ + filename: filename || "test_data", + num_random_lines: numLines, + tabular_data: tabularData, + }); + }; + + const handleReset = (): void => { + reset(); + setTabularData(SAMPLE_DATA); + setNumLines(3); + setFilename("variant_calls"); + }; + + const isJobInProgress = isSubmitting || isPolling; + const hasActiveJob = Boolean(jobId); + + return ( + + + Paste your genomic data below to search for patterns, variants, or + sequences of interest. Logan Search will analyze your data and return + matching results. + + + {/* Input Form */} + + +
+ + + Input Data + + + + setFilename(e.target.value)} + fullWidth + size="small" + disabled={isJobInProgress} + /> + + setNumLines(parseInt(e.target.value) || 1)} + inputProps={{ max: 100, min: 1 }} + size="small" + disabled={isJobInProgress} + /> + + + setTabularData(e.target.value)} + fullWidth + disabled={isJobInProgress} + helperText="Enter tab-separated genomic data (e.g., variants, sequences, annotations)" + sx={{ mt: 2 }} + /> + + + + + {isPolling && ( + + )} + + {hasActiveJob && ( + + )} + + +
+
+
+ + {/* Errors */} + {submissionError && ( + {}}> + Submission Error: {submissionError} + + )} + + {pollingError && ( + + Polling Error: {pollingError} + + )} + + {resultsError && ( + + Results Error: {resultsError} + + )} + + {/* Job Status and Results (consolidated) */} + {(jobId || jobStatus) && ( + <> + + + + )} + + {/* Instructions */} + + + + How it works + + + + Data is uploaded to Galaxy using the upload tool + + + The "Select random lines" tool is executed on the + uploaded data + + + Job status is polled every 2 seconds until completion + + + Results are retrieved and displayed when the job finishes + + + + + + This demonstrates the complete Galaxy API workflow: + + + + + + + + + + + + + +
+ ); +}; diff --git a/app/components/GalaxyJob/GalaxyJobHistory/galaxyJobHistory.tsx b/app/components/GalaxyJob/GalaxyJobHistory/galaxyJobHistory.tsx new file mode 100644 index 000000000..55efae579 --- /dev/null +++ b/app/components/GalaxyJob/GalaxyJobHistory/galaxyJobHistory.tsx @@ -0,0 +1,240 @@ +import React from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Chip, + Typography, +} from "@mui/material"; +import { ExpandMore, Delete, History } from "@mui/icons-material"; + +interface GalaxyJobHistoryItem { + completed_time?: string; + created_time: string; + filename?: string; + job_id: string; + num_random_lines: number; + results?: Record; + status: string; + tabular_data: string; + updated_time?: string; +} + +interface GalaxyJobHistoryProps { + jobHistory: GalaxyJobHistoryItem[]; + onClearHistory: () => void; +} + +const getStatusColor = (status: string): string => { + switch (status.toLowerCase()) { + case "ok": + case "completed": + return "success"; + case "running": + case "queued": + case "submitted": + return "info"; + case "paused": + return "warning"; + case "error": + case "failed": + return "error"; + default: + return "default"; + } +}; + +const formatDate = (dateString: string): string => { + try { + const date = new Date(dateString); + return date.toLocaleString(); + } catch { + return dateString; + } +}; + +const truncateText = (text: string, maxLength: number): string => { + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; +}; + +export const GalaxyJobHistory: React.FC = ({ + jobHistory, + onClearHistory, +}) => { + if (jobHistory.length === 0) { + return ( + + + + No job history yet. Submit a job to see it appear here. + + + ); + } + + return ( + + + + + Job History ({jobHistory.length}) + + + + + + {jobHistory.map((job, index) => ( + + }> + + + + Job {index + 1}: {job.job_id} + + + {formatDate(job.created_time)} • {job.num_random_lines}{" "} + lines + {job.filename && ` • ${job.filename}`} + + + + + + + + + Job Details: + + + Job ID: {job.job_id} + + + Created: {formatDate(job.created_time)} + + {job.updated_time && ( + + Updated: {formatDate(job.updated_time)} + + )} + {job.completed_time && ( + + Completed: {formatDate(job.completed_time)} + + )} + + Random Lines: {job.num_random_lines} + + {job.filename && ( + + Filename: {job.filename} + + )} + + Input Data: + + + + {truncateText(job.tabular_data, 500)} + + + + {job.results && ( + <> + + Job Results: + + {((): JSX.Element[] => { + console.log( + `📋 Displaying results for job ${job.job_id}:`, + job.results + ); + return Object.entries(job.results).map( + ([name, content]) => ( + + + {name}: + + + + {content} + + + + ) + ); + })()} + + )} + + + + ))} + + + ); +}; diff --git a/app/components/GalaxyJob/GalaxyJobResults/galaxyJobResults.tsx b/app/components/GalaxyJob/GalaxyJobResults/galaxyJobResults.tsx new file mode 100644 index 000000000..17fd163ac --- /dev/null +++ b/app/components/GalaxyJob/GalaxyJobResults/galaxyJobResults.tsx @@ -0,0 +1,262 @@ +import { + Card, + CardContent, + Typography, + Box, + Accordion, + AccordionSummary, + AccordionDetails, + Chip, + Alert, + LinearProgress, + Divider, +} from "@mui/material"; +import { ExpandMore, CheckCircle, Description } from "@mui/icons-material"; + +interface GalaxyJobOutput { + dataset: { + file_ext: string; + file_size?: number; + id: string; + name: string; + state: string; + }; + id: string; + name: string; +} + +interface GalaxyJobResult { + completed_time?: string; + created_time: string; + job_id: string; + outputs: GalaxyJobOutput[]; + processing_time?: string; + results: Record; + status: string; +} + +interface Props { + isLoading: boolean; + results: GalaxyJobResult | null; +} + +const formatDateTime = (dateTime: string): string => { + try { + return new Date(dateTime).toLocaleString(); + } catch { + return dateTime; + } +}; + +const formatFileSize = (bytes?: number): string => { + if (!bytes) return "Unknown size"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${Math.round(bytes / (1024 * 1024))} MB`; +}; + +const countLines = (content: string): number => { + return content.trim().split("\n").length; +}; + +export const GalaxyJobResults = ({ + isLoading, + results, +}: Props): JSX.Element => { + if (isLoading) { + return ( + + + + + + Loading results... + + + + + ); + } + + if (!results) { + return
; + } + + const processingTime = + results.completed_time && results.created_time + ? new Date(results.completed_time).getTime() - + new Date(results.created_time).getTime() + : null; + + return ( + + + + + Job Completed Successfully + + + {/* Summary Information */} + + + Job ID:{" "} + {results.job_id} + + + + + + {processingTime && ( + + )} + + + + + Created: {formatDateTime(results.created_time)} + + {results.completed_time && ( + + Completed: {formatDateTime(results.completed_time)} + + )} + + + + {/* Results Section */} + {Object.keys(results.results).length > 0 && ( + <> + + + Output Results + + + {Object.entries(results.results).map( + ([outputName, content], index) => { + const output = results.outputs.find( + (o) => o.name === outputName + ); + const lineCount = countLines(content); + + return ( + + }> + + + + {outputName} + + + + {output && ( + + )} + + + + + {output && ( + + + Dataset: {output.dataset.name} ( + {output.dataset.file_ext}) + + + State: {output.dataset.state} + + + )} + + + + {content} + + + + {lineCount > 10 && ( + + This output contains {lineCount} lines. Only the first + portion is shown above. + + )} + + + ); + } + )} + + )} + + {/* Empty Results */} + {Object.keys(results.results).length === 0 && ( + <> + + + Job completed successfully but no output content was retrieved. + This might indicate an issue with result retrieval or the outputs + may be empty. + + + )} + + {/* Success Message */} + + + ✅ Integration Test Successful! +
+ The Galaxy API integration is working correctly. Data was + successfully: +
+ +
  • Uploaded to Galaxy
  • +
  • Processed with the "Select random lines" tool
  • +
  • Retrieved as results
  • +
    +
    +
    +
    + ); +}; diff --git a/app/components/GalaxyJob/GalaxyJobStatus/galaxyJobStatus.tsx b/app/components/GalaxyJob/GalaxyJobStatus/galaxyJobStatus.tsx new file mode 100644 index 000000000..3a4872c07 --- /dev/null +++ b/app/components/GalaxyJob/GalaxyJobStatus/galaxyJobStatus.tsx @@ -0,0 +1,420 @@ +import { + Card, + CardContent, + Typography, + Chip, + LinearProgress, + Box, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Alert, +} from "@mui/material"; +import { + Schedule, + PlayArrow, + CheckCircle, + Error, + Pause, + Delete, + CloudUpload, + HourglassEmpty, + ExpandMore, + Description, +} from "@mui/icons-material"; + +interface GalaxyJobOutput { + dataset: { + file_ext: string; + file_size?: number; + id: string; + name: string; + state: string; + }; + id: string; + name: string; +} + +interface GalaxyJobStatus { + created_time: string; + exit_code?: number; + is_complete: boolean; + is_successful: boolean; + job_id: string; + outputs: GalaxyJobOutput[]; + state: string; + stderr?: string; + stdout?: string; + updated_time: string; +} + +interface Props { + isPolling: boolean; + jobId: string | null; + results?: Record | null; + status: GalaxyJobStatus | null; +} + +const getStateIcon = (state: string): JSX.Element => { + switch (state) { + case "new": + return ; + case "upload": + return ; + case "waiting": + case "queued": + return ; + case "running": + return ; + case "ok": + return ; + case "error": + return ; + case "paused": + return ; + case "deleted": + case "deleted_new": + return ; + default: + return ; + } +}; + +const getStateColor = ( + state: string +): + | "default" + | "primary" + | "secondary" + | "error" + | "warning" + | "info" + | "success" => { + switch (state) { + case "ok": + return "success"; + case "error": + return "error"; + case "running": + return "primary"; + case "waiting": + case "queued": + return "warning"; + case "paused": + return "secondary"; + case "deleted": + case "deleted_new": + return "error"; + default: + return "default"; + } +}; + +const formatDateTime = (dateTime: string): string => { + try { + return new Date(dateTime).toLocaleString(); + } catch { + return dateTime; + } +}; + +const formatFileSize = (bytes?: number): string => { + if (!bytes) return "Unknown size"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${Math.round(bytes / (1024 * 1024))} MB`; +}; + +const countLines = (content: string): number => { + return content.trim().split("\n").length; +}; + +export const GalaxyJobStatus = ({ + isPolling, + jobId, + results, + status, +}: Props): JSX.Element => { + if (!jobId) { + return
    ; + } + + const isCompleted = status?.is_complete && status?.state === "ok"; + const hasResults = results && Object.keys(results).length > 0; + + return ( + + + + {isCompleted ? ( + <> + + Search Complete + + ) : ( + Logan Search Status + )} + + + + + + Job ID: + + + {jobId} + + + + + + {isPolling && ( + + )} + + + + + {status && ( + <> + + + {getStateIcon(status.state)} + + {!status.is_complete && ( + + )} + + + + + Created: {formatDateTime(status.created_time)} + + + Updated: {formatDateTime(status.updated_time)} + + + + {status.outputs.length > 0 && ( + <> + + + Outputs ({status.outputs.length}) + + {status.outputs.map((output) => ( + + + {output.name}: {output.dataset.name} + {output.dataset.file_size && ( + <> + {" "} + ({Math.round(output.dataset.file_size / 1024)} KB) + + )} + + + + + + + ))} + + )} + + {/* Show stderr/stdout for debugging if job failed */} + {status.state === "error" && (status.stderr || status.stdout) && ( + <> + + + Error Information + + {status.stderr && ( + + + STDERR: + + + {status.stderr} + + + )} + {status.stdout && ( + + + STDOUT: + + + {status.stdout} + + + )} + + )} + + + )} + + {/* Results Section - Show when job is completed successfully */} + {isCompleted && hasResults && ( + <> + + + Search Results + + + {Object.entries(results!).map(([outputName, content], index) => { + const output = status!.outputs.find((o) => o.name === outputName); + const lineCount = countLines(content); + + return ( + + }> + + + {outputName} + + + {output && ( + + )} + + + + + {output && ( + + + Dataset: {output.dataset.name} ( + {output.dataset.file_ext}) + + + State: {output.dataset.state} + + + )} + + + + {content} + + + + {lineCount > 10 && ( + + This output contains {lineCount} lines. Only the first + portion is shown above. + + )} + + + ); + })} + + {/* Success Message */} + + + ✅ Analysis Complete! +
    + Your Logan Search analysis has completed successfully. +
    +
    + + )} + + {!status && ( + + + + Waiting for job status... + + + )} +
    +
    + ); +}; diff --git a/app/components/GalaxyJob/galaxyJob.tsx b/app/components/GalaxyJob/galaxyJob.tsx new file mode 100644 index 000000000..f3b5d5d79 --- /dev/null +++ b/app/components/GalaxyJob/galaxyJob.tsx @@ -0,0 +1,32 @@ +import { Container, Typography, Box } from "@mui/material"; +import { useGalaxyJob } from "../../hooks/useGalaxyJob"; +import { GalaxyJobForm } from "./GalaxyJobForm/galaxyJobForm"; +import { GalaxyJobHistory } from "./GalaxyJobHistory/galaxyJobHistory"; + +export const GalaxyJob = (): JSX.Element => { + const galaxyJobHook = useGalaxyJob(); + + return ( + + + + Logan Search + + + Analyze genomic data using Logan Search powered by Galaxy + + + + + + ); +}; diff --git a/app/hooks/useGalaxyJob.ts b/app/hooks/useGalaxyJob.ts new file mode 100644 index 000000000..2817ad388 --- /dev/null +++ b/app/hooks/useGalaxyJob.ts @@ -0,0 +1,494 @@ +import { useState, useCallback, useEffect } from "react"; +import ky from "ky"; + +// Types based on our backend models +interface GalaxyJobSubmission { + filename?: string; + num_random_lines: number; + tabular_data: string; +} + +interface GalaxyJobResponse { + job_id: string; + message: string; + status: string; + upload_dataset_id: string; +} + +interface GalaxyJobOutput { + dataset: { + file_ext: string; + file_size?: number; + id: string; + name: string; + state: string; + }; + id: string; + name: string; +} + +interface GalaxyJobStatus { + created_time: string; + exit_code?: number; + is_complete: boolean; + is_successful: boolean; + job_id: string; + outputs: GalaxyJobOutput[]; + state: string; + stderr?: string; + stdout?: string; + updated_time: string; +} + +interface GalaxyJobResult { + completed_time?: string; + created_time: string; + job_id: string; + outputs: GalaxyJobOutput[]; + processing_time?: string; + results: Record; + status: string; +} + +// Job history item for localStorage +interface GalaxyJobHistoryItem { + completed_time?: string; + created_time: string; + filename?: string; + job_id: string; + num_random_lines: number; + results?: Record; + status: string; + tabular_data: string; + updated_time?: string; +} + +// Hook state interface +interface GalaxyJobState { + // Results retrieval state + isLoadingResults: boolean; + // Polling state + isPolling: boolean; + // Submission state + isSubmitting: boolean; + + // Job tracking state + jobHistory: GalaxyJobHistoryItem[]; + jobId: string | null; + jobResults: GalaxyJobResult | null; + jobStatus: GalaxyJobStatus | null; + + // Error states + pollingError: string | null; + resultsError: string | null; + submissionError: string | null; +} + +interface GalaxyJobActions { + clearJobHistory: () => void; + getResults: (jobId: string) => Promise; + reset: () => void; + startPolling: (jobId: string) => void; + stopPolling: () => void; + submitJob: (submission: GalaxyJobSubmission) => Promise; +} + +const BACKEND_URL = "http://localhost:8000"; +const POLLING_INTERVAL = 2000; // 2 seconds +const STORAGE_KEY = "galaxy_job_history"; + +// localStorage utilities +const loadJobHistory = (): GalaxyJobHistoryItem[] => { + if (typeof window === "undefined") return []; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch (error: unknown) { + console.error("Failed to load job history from localStorage:", error); + return []; + } +}; + +const saveJobHistory = (history: GalaxyJobHistoryItem[]): void => { + if (typeof window === "undefined") return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + } catch (error: unknown) { + console.error("Failed to save job history to localStorage:", error); + } +}; + +const addJobToHistory = (job: GalaxyJobHistoryItem): void => { + const history = loadJobHistory(); + // Add new job at the beginning (most recent first) + const updatedHistory = [ + job, + ...history.filter((h) => h.job_id !== job.job_id), + ]; + // Keep only the most recent 50 jobs + const trimmedHistory = updatedHistory.slice(0, 50); + saveJobHistory(trimmedHistory); +}; + +const updateJobInHistory = ( + jobId: string, + updates: Partial +): void => { + console.log(`📝 Updating job ${jobId} in history with:`, updates); + const history = loadJobHistory(); + const updatedHistory = history.map((job) => + job.job_id === jobId ? { ...job, ...updates } : job + ); + saveJobHistory(updatedHistory); + console.log(`✅ Job ${jobId} updated in history`); +}; + +export const useGalaxyJob = (): GalaxyJobState & GalaxyJobActions => { + const [state, setState] = useState(() => ({ + isLoadingResults: false, + isPolling: false, + isSubmitting: false, + jobHistory: [], + jobId: null, + jobResults: null, + jobStatus: null, + pollingError: null, + resultsError: null, + submissionError: null, + })); + + const [pollingIntervalId, setPollingIntervalId] = + useState(null); + + // Load job history on mount (client-side only) + useEffect((): void => { + if (typeof window !== "undefined") { + const history = loadJobHistory(); + setState((prev) => ({ ...prev, jobHistory: history })); + } + }, []); + + // Submit job to Galaxy + const submitJob = useCallback( + async (submission: GalaxyJobSubmission): Promise => { + setState((prev) => ({ + ...prev, + isSubmitting: true, + jobId: null, + jobResults: null, + jobStatus: null, + submissionError: null, + })); + + try { + const response = await ky + .post(`${BACKEND_URL}/api/v1/galaxy/submit-job`, { + json: submission, + timeout: 60000, // 60 seconds + }) + .json(); + + // Create job history item + const historyItem: GalaxyJobHistoryItem = { + created_time: new Date().toISOString(), + filename: submission.filename, + job_id: response.job_id, + num_random_lines: submission.num_random_lines, + status: "submitted", + tabular_data: submission.tabular_data, + }; + + // Save to localStorage + addJobToHistory(historyItem); + + // Update state with new job and refresh history + const updatedHistory = loadJobHistory(); + setState((prev) => ({ + ...prev, + isSubmitting: false, + jobHistory: updatedHistory, + jobId: response.job_id, + })); + + // Will start polling via useEffect when jobId changes + } catch (error: unknown) { + let errorMessage = "Failed to submit job"; + if (error && typeof error === "object" && "response" in error) { + const httpError = error as { + response: { + json: () => Promise<{ detail?: string }>; + status: number; + statusText: string; + }; + }; + try { + const errorData = await httpError.response.json(); + errorMessage = errorData.detail || errorMessage; + } catch { + errorMessage = `HTTP ${httpError.response.status}: ${httpError.response.statusText}`; + } + } else if (error && typeof error === "object" && "message" in error) { + errorMessage = (error as Error).message || errorMessage; + } + + setState((prev) => ({ + ...prev, + isSubmitting: false, + submissionError: errorMessage, + })); + } + }, + [] + ); + + // Get job status + const getJobStatus = useCallback( + async (jobId: string): Promise => { + try { + const response = await ky + .get(`${BACKEND_URL}/api/v1/galaxy/jobs/${jobId}/status`, { + timeout: 30000, + }) + .json(); + + return response; + } catch (error: unknown) { + console.error("Failed to get job status:", error); + return null; + } + }, + [] + ); + + // Get job results + const getResults = useCallback(async (jobId: string): Promise => { + console.log(`🔍 Getting results for job ${jobId}`); + setState((prev) => ({ + ...prev, + isLoadingResults: true, + resultsError: null, + })); + + try { + const response = await ky + .get(`${BACKEND_URL}/api/v1/galaxy/jobs/${jobId}/results`, { + timeout: 30000, + }) + .json(); + + console.log(`✅ Got results for job ${jobId}:`, response); + + setState((prev) => ({ + ...prev, + isLoadingResults: false, + jobResults: response, + })); + + // Save results to localStorage + console.log(`💾 Saving results to localStorage for job ${jobId}`); + updateJobInHistory(jobId, { + completed_time: response.completed_time, + results: response.results, + status: response.status, + }); + console.log(`✅ Results saved to localStorage for job ${jobId}`); + } catch (error: unknown) { + let errorMessage = "Failed to get results"; + if (error && typeof error === "object" && "response" in error) { + const httpError = error as { + response: { + json: () => Promise<{ detail?: string }>; + status: number; + statusText: string; + }; + }; + try { + const errorData = await httpError.response.json(); + errorMessage = errorData.detail || errorMessage; + } catch { + errorMessage = `HTTP ${httpError.response.status}: ${httpError.response.statusText}`; + } + } else if (error && typeof error === "object" && "message" in error) { + errorMessage = (error as Error).message || errorMessage; + } + + setState((prev) => ({ + ...prev, + isLoadingResults: false, + resultsError: errorMessage, + })); + } + }, []); + + // Start polling job status + const startPolling = useCallback( + (jobId: string): void => { + // Clear any existing polling + if (pollingIntervalId) { + clearInterval(pollingIntervalId); + } + + setState((prev) => ({ + ...prev, + isPolling: true, + pollingError: null, + })); + + // Poll immediately + getJobStatus(jobId).then((status) => { + if (status) { + setState((prev) => ({ ...prev, jobStatus: status })); + + // Update job status in history + updateJobInHistory(jobId, { + status: status.state, + updated_time: status.updated_time, + }); + + // If job is complete, stop polling and get results + if (status.is_complete) { + console.log( + `🏁 Job ${jobId} completed with status: ${status.state}, successful: ${status.is_successful}` + ); + // Stop polling inline + if (pollingIntervalId) { + clearInterval(pollingIntervalId); + setPollingIntervalId(null); + } + setState((prev) => ({ ...prev, isPolling: false })); + + if (status.is_successful) { + console.log( + `🚀 Job ${jobId} was successful, fetching results...` + ); + // Get results for completed successful jobs + getResults(jobId); + } else { + console.log( + `❌ Job ${jobId} was not successful, skipping results fetch` + ); + } + } + } + }); + + // Set up interval polling + const intervalId = setInterval(async () => { + const status = await getJobStatus(jobId); + if (status) { + setState((prev) => ({ ...prev, jobStatus: status })); + + // Update job status in history + updateJobInHistory(jobId, { + status: status.state, + updated_time: status.updated_time, + }); + + // If job is complete, stop polling and get results + if (status.is_complete) { + console.log( + `🏁 Job ${jobId} completed in polling loop with status: ${status.state}, successful: ${status.is_successful}` + ); + // Stop polling inline + clearInterval(intervalId); + setPollingIntervalId(null); + setState((prev) => ({ ...prev, isPolling: false })); + + if (status.is_successful) { + console.log( + `🚀 Job ${jobId} was successful in polling loop, fetching results...` + ); + // Get results for completed successful jobs + getResults(jobId); + } else { + console.log( + `❌ Job ${jobId} was not successful in polling loop, skipping results fetch` + ); + } + } + } else { + setState((prev) => ({ + ...prev, + pollingError: "Failed to get job status", + })); + } + }, POLLING_INTERVAL); + + setPollingIntervalId(intervalId); + }, + [pollingIntervalId, getJobStatus, getResults] + ); + + // Stop polling + const stopPolling = useCallback((): void => { + if (pollingIntervalId) { + clearInterval(pollingIntervalId); + setPollingIntervalId(null); + } + setState((prev) => ({ + ...prev, + isPolling: false, + })); + }, [pollingIntervalId]); + + // Clear job history + const clearJobHistory = useCallback((): void => { + if (typeof window !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + setState((prev) => ({ ...prev, jobHistory: [] })); + }, []); + + // Reset all state + const reset = useCallback((): void => { + if (pollingIntervalId) { + clearInterval(pollingIntervalId); + setPollingIntervalId(null); + } + + const currentHistory = loadJobHistory(); + setState({ + isLoadingResults: false, + isPolling: false, + isSubmitting: false, + jobHistory: currentHistory, + jobId: null, + jobResults: null, + jobStatus: null, + pollingError: null, + resultsError: null, + submissionError: null, + }); + }, [pollingIntervalId]); + + // Auto-start polling when a new job is submitted + useEffect((): void => { + if (state.jobId && !state.isPolling && !state.jobResults) { + startPolling(state.jobId); + } + }, [state.jobId, state.isPolling, state.jobResults, startPolling]); + + // Cleanup on unmount + useEffect((): (() => void) => { + return (): void => { + if (pollingIntervalId) { + clearInterval(pollingIntervalId); + } + }; + }, [pollingIntervalId]); + + return { + // State + ...state, + + // Actions + clearJobHistory, + getResults, + reset, + startPolling, + stopPolling, + submitJob, + }; +}; diff --git a/app/views/GalaxyJobView/galaxyJobView.styles.ts b/app/views/GalaxyJobView/galaxyJobView.styles.ts new file mode 100644 index 000000000..3bb6dadcb --- /dev/null +++ b/app/views/GalaxyJobView/galaxyJobView.styles.ts @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; +import { GridPaperSection } from "@databiosphere/findable-ui/lib/components/common/Section/section.styles"; + +export const TestSection = styled(GridPaperSection)` + && { + margin: 0 auto; + max-width: 1200px; + padding: 32px 20px; + } +`; + +export const TestContainer = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + margin: 0 auto; + max-width: 800px; +`; diff --git a/app/views/GalaxyJobView/galaxyJobView.tsx b/app/views/GalaxyJobView/galaxyJobView.tsx new file mode 100644 index 000000000..5b89bbc2f --- /dev/null +++ b/app/views/GalaxyJobView/galaxyJobView.tsx @@ -0,0 +1,26 @@ +import { Fragment } from "react"; +import { SectionHero } from "../../components/Layout/components/AppLayout/components/Section/components/SectionHero/sectionHero"; +import { GalaxyJob } from "../../components/GalaxyJob/galaxyJob"; +import { TestContainer, TestSection } from "./galaxyJobView.styles"; + +const BREADCRUMBS = [ + { path: "/", text: "Home" }, + { path: "/galaxy-test", text: "Galaxy Integration Test" }, +]; + +export const GalaxyJobView = (): JSX.Element => { + return ( + + + + + + + + + ); +}; diff --git a/backend/.env.example b/backend/.env.example index f786a7588..14b7a7353 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,14 @@ REDIS_URL=redis://redis:6379/0 # Database Configuration (for future use) DATABASE_URL=postgresql://user:pass@localhost/dbname +# Galaxy API Configuration +GALAXY_API_URL=https://test.galaxyproject.org/api +GALAXY_API_KEY=your_galaxy_service_account_key_here + +# Galaxy Tool IDs (can be customized if needed) +GALAXY_UPLOAD_TOOL_ID=upload1 +GALAXY_RANDOM_LINES_TOOL_ID=random_lines1 + # Application Configuration CORS_ORIGINS=http://localhost:3000,http://localhost LOG_LEVEL=INFO diff --git a/backend/app/api/v1/galaxy.py b/backend/app/api/v1/galaxy.py new file mode 100644 index 000000000..fd5bd25be --- /dev/null +++ b/backend/app/api/v1/galaxy.py @@ -0,0 +1,253 @@ +"""Galaxy API integration endpoints.""" + +import logging +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from typing import Dict, Any + +from app.core.dependencies import get_cache_service +from app.core.cache import CacheService +from app.services.galaxy_service import GalaxyService +from app.models.galaxy import ( + GalaxyJobSubmission, + GalaxyJobResponse, + GalaxyJobStatus, + GalaxyJobResult, + GalaxyJobState +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +async def get_galaxy_service(cache: CacheService = Depends(get_cache_service)) -> GalaxyService: + """Dependency to get Galaxy service instance.""" + return GalaxyService(cache) + + +@router.get("/health") +async def galaxy_health(galaxy_service: GalaxyService = Depends(get_galaxy_service)): + """Check Galaxy service health and configuration.""" + return { + "status": "healthy" if galaxy_service.is_available() else "unavailable", + "galaxy_configured": galaxy_service.is_available(), + "api_url": galaxy_service.settings.GALAXY_API_URL, + "upload_tool_id": galaxy_service.settings.GALAXY_UPLOAD_TOOL_ID, + "random_lines_tool_id": galaxy_service.settings.GALAXY_RANDOM_LINES_TOOL_ID + } + + +@router.post("/submit-job", response_model=GalaxyJobResponse) +async def submit_galaxy_job( + submission: GalaxyJobSubmission, + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """ + Submit a job to Galaxy: upload tabular data and run random lines tool. + + Returns job ID for tracking the random lines tool execution. + """ + try: + if not galaxy_service.is_available(): + raise HTTPException( + status_code=503, + detail="Galaxy service is not available. Please check configuration." + ) + + # Validate input data + if not submission.tabular_data.strip(): + raise HTTPException( + status_code=400, + detail="Tabular data cannot be empty" + ) + + if submission.num_random_lines <= 0: + raise HTTPException( + status_code=400, + detail="Number of random lines must be greater than 0" + ) + + # Check if data has multiple lines + lines = submission.tabular_data.strip().split('\n') + if len(lines) < submission.num_random_lines: + raise HTTPException( + status_code=400, + detail=f"Cannot select {submission.num_random_lines} lines from {len(lines)} lines of data" + ) + + logger.info(f"Submitting Galaxy job with {len(lines)} lines of data, selecting {submission.num_random_lines} random lines") + + # Submit job to Galaxy + response = await galaxy_service.submit_job(submission) + + logger.info(f"Galaxy job submitted successfully: {response.job_id}") + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to submit Galaxy job: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to submit job to Galaxy: {str(e)}" + ) + + +@router.get("/jobs/{job_id}/status", response_model=GalaxyJobStatus) +async def get_job_status( + job_id: str, + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """Get the current status of a Galaxy job.""" + try: + if not galaxy_service.is_available(): + raise HTTPException( + status_code=503, + detail="Galaxy service is not available" + ) + + logger.debug(f"Getting status for job: {job_id}") + status = await galaxy_service.get_job_status(job_id) + + return status + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get job status for {job_id}: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get job status: {str(e)}" + ) + + +@router.get("/jobs/{job_id}/results", response_model=GalaxyJobResult) +async def get_job_results( + job_id: str, + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """Get the complete results from a finished Galaxy job.""" + try: + if not galaxy_service.is_available(): + raise HTTPException( + status_code=503, + detail="Galaxy service is not available" + ) + + logger.debug(f"Getting results for job: {job_id}") + results = await galaxy_service.get_job_results(job_id) + + return results + + except HTTPException: + raise + except Exception as e: + # Check if it's a "job not complete" error + if "not yet complete" in str(e): + raise HTTPException( + status_code=202, # Accepted but processing not complete + detail=str(e) + ) + elif "failed" in str(e).lower(): + raise HTTPException( + status_code=422, # Unprocessable Entity - job failed + detail=str(e) + ) + else: + logger.error(f"Failed to get job results for {job_id}: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get job results: {str(e)}" + ) + + +@router.get("/jobs/{job_id}", response_model=Dict[str, Any]) +async def get_job_details( + job_id: str, + include_results: bool = False, + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """ + Get comprehensive job information including status and optionally results. + + This is a convenience endpoint that combines status and results. + """ + try: + if not galaxy_service.is_available(): + raise HTTPException( + status_code=503, + detail="Galaxy service is not available" + ) + + # Always get status + status = await galaxy_service.get_job_status(job_id) + + response = { + "job_id": job_id, + "status": status.dict() + } + + # Include results if requested and job is complete + if include_results and status.is_complete and status.is_successful: + try: + results = await galaxy_service.get_job_results(job_id) + response["results"] = results.dict() + except Exception as e: + logger.warning(f"Failed to get results for completed job {job_id}: {e}") + response["results_error"] = str(e) + + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get job details for {job_id}: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get job details: {str(e)}" + ) + + +@router.delete("/jobs/{job_id}") +async def cancel_job( + job_id: str, + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """Cancel a running Galaxy job (future implementation).""" + # Note: This would require implementing job cancellation in GalaxyService + # For now, just return a placeholder response + raise HTTPException( + status_code=501, + detail="Job cancellation not yet implemented" + ) + + +# Example endpoint for testing Galaxy connectivity +@router.post("/test-connection") +async def test_galaxy_connection( + galaxy_service: GalaxyService = Depends(get_galaxy_service) +): + """Test connection to Galaxy API (admin/debug endpoint).""" + try: + if not galaxy_service.is_available(): + return { + "status": "error", + "message": "Galaxy service not configured", + "configured": False + } + + # Try a simple API call to test connectivity + # This could be improved to actually test API access + return { + "status": "success", + "message": "Galaxy service is configured and available", + "configured": True, + "api_url": galaxy_service.settings.GALAXY_API_URL + } + + except Exception as e: + logger.error(f"Galaxy connection test failed: {e}") + return { + "status": "error", + "message": f"Connection test failed: {str(e)}", + "configured": True + } \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ef69175e9..59137e37a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -15,6 +15,14 @@ class Settings: # Database settings (for future use) DATABASE_URL: str = os.getenv("DATABASE_URL", "") + # Galaxy API settings + GALAXY_API_URL: str = os.getenv("GALAXY_API_URL", "https://test.galaxyproject.org/api") + GALAXY_API_KEY: str = os.getenv("GALAXY_API_KEY", "") + + # Galaxy Tool IDs + GALAXY_UPLOAD_TOOL_ID: str = os.getenv("GALAXY_UPLOAD_TOOL_ID", "upload1") + GALAXY_RANDOM_LINES_TOOL_ID: str = os.getenv("GALAXY_RANDOM_LINES_TOOL_ID", "random_lines1") + # CORS settings CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", "http://localhost:3000").split( "," diff --git a/backend/app/main.py b/backend/app/main.py index 3d8521463..1558901f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1 import cache, health, version +from app.api.v1 import cache, galaxy, health, version from app.core.config import get_settings settings = get_settings() @@ -25,6 +25,7 @@ app.include_router(health.router, prefix="/api/v1", tags=["health"]) app.include_router(cache.router, prefix="/api/v1/cache", tags=["cache"]) app.include_router(version.router, prefix="/api/v1/version", tags=["version"]) +app.include_router(galaxy.router, prefix="/api/v1/galaxy", tags=["galaxy"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 000000000..4e0130476 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +"""Data models for the BRC Analytics API""" \ No newline at end of file diff --git a/backend/app/models/galaxy.py b/backend/app/models/galaxy.py new file mode 100644 index 000000000..3e943c12b --- /dev/null +++ b/backend/app/models/galaxy.py @@ -0,0 +1,123 @@ +"""Galaxy API integration models.""" + +from typing import Dict, List, Optional, Any +from pydantic import BaseModel, Field +from enum import Enum + + +class GalaxyJobState(str, Enum): + """Galaxy job states as defined in the API.""" + NEW = "new" + UPLOAD = "upload" + WAITING = "waiting" + QUEUED = "queued" + RUNNING = "running" + OK = "ok" + ERROR = "error" + PAUSED = "paused" + DELETED = "deleted" + DELETED_NEW = "deleted_new" + + +class GalaxyJobSubmission(BaseModel): + """Request model for submitting a job to Galaxy.""" + tabular_data: str = Field(..., description="Tabular data content (TSV format)") + num_random_lines: int = Field(default=10, ge=1, le=1000, description="Number of random lines to select") + filename: Optional[str] = Field(default="input_data", description="Name for the uploaded file") + + +class GalaxyJobResponse(BaseModel): + """Response model for job submission.""" + job_id: str = Field(..., description="Galaxy job ID for tracking") + upload_dataset_id: str = Field(..., description="ID of the uploaded dataset") + status: str = Field(default="submitted", description="Initial job status") + message: str = Field(default="Job submitted successfully") + + +class GalaxyDataset(BaseModel): + """Model for Galaxy dataset information.""" + id: str + name: str + state: str + file_ext: str + file_size: Optional[int] = None + created_time: Optional[str] = None + updated_time: Optional[str] = None + + +class GalaxyJobOutput(BaseModel): + """Model for Galaxy job output information.""" + id: str + name: str + dataset: GalaxyDataset + + +class GalaxyJobDetails(BaseModel): + """Detailed information about a Galaxy job.""" + id: str + tool_id: str + state: GalaxyJobState + created_time: str + updated_time: str + outputs: List[GalaxyJobOutput] = [] + inputs: Dict[str, Any] = {} + stdout: Optional[str] = None + stderr: Optional[str] = None + exit_code: Optional[int] = None + + +class GalaxyJobStatus(BaseModel): + """Status response for a Galaxy job.""" + job_id: str + state: GalaxyJobState + created_time: str + updated_time: str + is_complete: bool = Field(default=False, description="Whether the job has finished (success or failure)") + is_successful: bool = Field(default=False, description="Whether the job completed successfully") + outputs: List[GalaxyJobOutput] = [] + stdout: Optional[str] = None + stderr: Optional[str] = None + exit_code: Optional[int] = None + + +class GalaxyJobResult(BaseModel): + """Final results from a completed Galaxy job.""" + job_id: str + status: GalaxyJobState + outputs: List[GalaxyJobOutput] + results: Dict[str, str] = Field(default_factory=dict, description="Output dataset contents") + processing_time: Optional[str] = None + created_time: str + completed_time: Optional[str] = None + + +class GalaxyAPIError(BaseModel): + """Model for Galaxy API errors.""" + error: str + message: str + status_code: int + job_id: Optional[str] = None + + +# Internal Galaxy API request/response models (for service layer) + +class GalaxyUploadRequest(BaseModel): + """Internal model for Galaxy upload tool request.""" + tool_id: str + history_id: str + inputs: Dict[str, Any] + + +class GalaxyToolRequest(BaseModel): + """Internal model for Galaxy tool execution request.""" + tool_id: str + history_id: str + inputs: Dict[str, Any] + + +class GalaxyAPIResponse(BaseModel): + """Generic Galaxy API response wrapper.""" + success: bool = True + data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + status_code: int = 200 \ No newline at end of file diff --git a/backend/app/services/galaxy_service.py b/backend/app/services/galaxy_service.py new file mode 100644 index 000000000..871a4404c --- /dev/null +++ b/backend/app/services/galaxy_service.py @@ -0,0 +1,340 @@ +"""Galaxy API integration service using BioBLEND.""" + +import asyncio +import logging +from typing import Dict, List, Optional, Any, Tuple +from bioblend.galaxy import GalaxyInstance +from bioblend.galaxy.histories import HistoryClient +from bioblend.galaxy.tools import ToolClient +from bioblend.galaxy.jobs import JobsClient +from bioblend.galaxy.datasets import DatasetClient +from app.core.config import get_settings +from app.core.cache import CacheService, CacheTTL +from app.models.galaxy import ( + GalaxyJobSubmission, + GalaxyJobResponse, + GalaxyJobDetails, + GalaxyJobStatus, + GalaxyJobResult, + GalaxyJobState, + GalaxyJobOutput, + GalaxyDataset, + GalaxyAPIError, + GalaxyAPIResponse +) + +logger = logging.getLogger(__name__) + + +class GalaxyService: + """Service for interacting with Galaxy API using BioBLEND.""" + + def __init__(self, cache: CacheService): + self.cache = cache + self.settings = get_settings() + + # Check if Galaxy is configured + if not self.settings.GALAXY_API_KEY: + logger.warning("Galaxy API key not configured - Galaxy features will be disabled") + self._galaxy_available = False + self.gi = None + else: + self._galaxy_available = True + # Initialize BioBLEND Galaxy instance + self.gi = GalaxyInstance( + url=self.settings.GALAXY_API_URL.replace('/api', ''), # BioBLEND expects base URL without /api + key=self.settings.GALAXY_API_KEY + ) + logger.info(f"Galaxy service initialized with BioBLEND for URL: {self.settings.GALAXY_API_URL}") + + # Shared BRC Analytics history + self._shared_history_id = None + + def is_available(self) -> bool: + """Check if Galaxy service is available.""" + return self._galaxy_available and bool(self.settings.GALAXY_API_KEY) + + async def submit_job(self, submission: GalaxyJobSubmission) -> GalaxyJobResponse: + """ + Submit a complete job: upload data and run the random lines tool. + + Returns job ID for tracking the random lines tool execution. + """ + if not self.is_available(): + raise Exception("Galaxy service not available - check API key configuration") + + logger.info(f"Submitting Galaxy job with {len(submission.tabular_data)} chars of data") + + try: + # Step 0: Get or create the shared BRC Analytics history + history_id = await self._get_or_create_shared_history() + + # Step 1: Upload the tabular data + upload_dataset_id = await self._upload_tabular_data( + submission.tabular_data, + submission.filename, + history_id + ) + + # Step 2: Run the random lines tool on the uploaded data + job_id = await self._run_random_lines_tool( + upload_dataset_id, + submission.num_random_lines, + history_id + ) + + return GalaxyJobResponse( + job_id=job_id, + upload_dataset_id=upload_dataset_id, + status="submitted", + message=f"Job {job_id} submitted successfully" + ) + + except Exception as e: + logger.error(f"Failed to submit Galaxy job: {str(e)}") + raise Exception(f"Galaxy job submission failed: {str(e)}") + + async def get_job_status(self, job_id: str) -> GalaxyJobStatus: + """Get the current status of a Galaxy job using BioBLEND.""" + if not self.is_available(): + raise Exception("Galaxy service not available") + + # Check cache first + cache_key = self.cache.make_key("galaxy:job_status", {"job_id": job_id}) + cached_status = await self.cache.get(cache_key) + + if cached_status and cached_status.get("state") in ["ok", "error"]: + # Job is complete, return cached result + return GalaxyJobStatus(**cached_status) + + try: + # Use BioBLEND to get job details + job_data = self.gi.jobs.show_job(job_id) + + # Debug: log the full job data response + logger.info(f"BioBLEND job {job_id} full response: {job_data}") + + # Parse job status + status = GalaxyJobStatus( + job_id=job_id, + state=GalaxyJobState(job_data["state"]), + created_time=job_data["create_time"], + updated_time=job_data["update_time"], + is_complete=job_data["state"] in ["ok", "error", "deleted"], + is_successful=job_data["state"] == "ok", + stdout=job_data.get("stdout"), + stderr=job_data.get("stderr"), + exit_code=job_data.get("exit_code") + ) + + # Debug: log state changes + logger.info(f"BioBLEND Job {job_id} current state: {job_data['state']}, complete: {status.is_complete}, successful: {status.is_successful}") + + # If job is complete, get outputs + if status.is_complete: + status.outputs = await self._get_job_outputs(job_id) + # Cache completed job status for 1 hour + await self.cache.set(cache_key, status.dict(), CacheTTL.ONE_HOUR) + + return status + + except Exception as e: + logger.error(f"BioBLEND error getting job status: {e}") + raise Exception(f"Failed to get job status using BioBLEND: {str(e)}") + + async def get_job_results(self, job_id: str) -> GalaxyJobResult: + """Get the complete results from a finished Galaxy job.""" + if not self.is_available(): + raise Exception("Galaxy service not available") + + # Check cache first + cache_key = self.cache.make_key("galaxy:job_results", {"job_id": job_id}) + cached_results = await self.cache.get(cache_key) + if cached_results: + return GalaxyJobResult(**cached_results) + + try: + # Get job status first + status = await self.get_job_status(job_id) + + if not status.is_complete: + raise Exception(f"Job {job_id} is not yet complete (state: {status.state})") + + if not status.is_successful: + raise Exception(f"Job {job_id} failed with state: {status.state}") + + # Get output contents + results = {} + for output in status.outputs: + try: + content = await self._get_dataset_content(output.dataset.id) + results[output.name] = content + except Exception as e: + logger.warning(f"Failed to get content for output {output.name}: {e}") + results[output.name] = f"Error retrieving content: {str(e)}" + + # Create result object + result = GalaxyJobResult( + job_id=job_id, + status=status.state, + outputs=status.outputs, + results=results, + created_time=status.created_time, + completed_time=status.updated_time + ) + + # Cache results for 24 hours + await self.cache.set(cache_key, result.dict(), CacheTTL.ONE_DAY) + return result + + except Exception as e: + logger.error(f"Error getting job results: {e}") + raise Exception(f"Failed to get job results: {str(e)}") + + + async def _upload_tabular_data(self, data: str, filename: str, history_id: str) -> str: + """Upload tabular data to Galaxy using BioBLEND and return dataset ID.""" + try: + # Use BioBLEND's paste_content method for uploading text data + upload_result = self.gi.tools.paste_content( + content=data, + history_id=history_id, + file_name=filename, + file_type='tabular' + ) + + logger.info(f"BioBLEND upload response: {upload_result}") + + # Get the output dataset ID from the outputs + outputs = upload_result.get("outputs", []) + if not outputs: + raise Exception("No outputs returned from BioBLEND upload") + + dataset_id = outputs[0]["id"] + logger.info(f"Uploaded data to dataset: {dataset_id} using BioBLEND") + return dataset_id + + except Exception as e: + logger.error(f"BioBLEND upload failed: {e}") + raise Exception(f"Failed to upload data using BioBLEND: {str(e)}") + + async def _run_random_lines_tool(self, input_dataset_id: str, num_lines: int, history_id: str) -> str: + """Run the random lines tool using BioBLEND and return job ID.""" + try: + tool_inputs = { + "input": { + "src": "hda", + "id": input_dataset_id + }, + "num_lines": str(num_lines), + "seed_source|seed_source_selector": "no_seed" + } + + # Use BioBLEND to run the tool + tool_response = self.gi.tools.run_tool( + history_id=history_id, + tool_id=self.settings.GALAXY_RANDOM_LINES_TOOL_ID, + tool_inputs=tool_inputs + ) + + logger.info(f"BioBLEND tool response: {tool_response}") + + # Get the job ID + jobs = tool_response.get("jobs", []) + if not jobs: + raise Exception("No jobs returned from BioBLEND tool execution") + + job_id = jobs[0]["id"] + logger.info(f"Started random lines tool with BioBLEND job ID: {job_id}") + return job_id + + except Exception as e: + logger.error(f"BioBLEND tool execution failed: {e}") + raise Exception(f"Failed to run random lines tool using BioBLEND: {str(e)}") + + async def _get_job_outputs(self, job_id: str) -> List[GalaxyJobOutput]: + """Get output information for a job using BioBLEND.""" + try: + # Get job outputs using BioBLEND + job_details = self.gi.jobs.show_job(job_id) + outputs = [] + + # Get outputs from job details + job_outputs = job_details.get("outputs", {}) + + for output_name, output_data in job_outputs.items(): + # Get dataset details using BioBLEND + dataset_details = self.gi.datasets.show_dataset(output_data["id"]) + + dataset_info = GalaxyDataset( + id=dataset_details["id"], + name=dataset_details["name"], + state=dataset_details["state"], + file_ext=dataset_details.get("file_ext", "txt"), + file_size=dataset_details.get("file_size"), + created_time=dataset_details.get("created_time"), + updated_time=dataset_details.get("updated_time") + ) + + output = GalaxyJobOutput( + id=dataset_details["id"], + name=output_name, + dataset=dataset_info + ) + outputs.append(output) + + return outputs + + except Exception as e: + logger.error(f"BioBLEND error getting job outputs: {e}") + # Fallback to empty outputs list + return [] + + async def _get_dataset_content(self, dataset_id: str) -> str: + """Get the actual content of a dataset using BioBLEND.""" + try: + # Use BioBLEND to download dataset content + content = self.gi.datasets.download_dataset(dataset_id) + if isinstance(content, bytes): + return content.decode('utf-8') + return str(content) + + except Exception as e: + logger.error(f"BioBLEND error getting dataset content: {e}") + return f"Error retrieving dataset content: {str(e)}" + + async def _get_or_create_shared_history(self) -> str: + """Get or create the shared 'BRC ANALYTICS JOBS' history using BioBLEND.""" + shared_history_name = "BRC ANALYTICS JOBS" + + if self._shared_history_id: + return self._shared_history_id + + try: + # Get all histories using BioBLEND + histories = self.gi.histories.get_histories() + + # Look for existing shared history + for history in histories: + if history.get("name") == shared_history_name: + history_id = history["id"] + logger.info(f"Using existing shared history: {history_id} ({shared_history_name})") + self._shared_history_id = history_id + return history_id + + # If we get here, the shared history doesn't exist, so create it + logger.info(f"Creating new shared history: {shared_history_name}") + new_history = self.gi.histories.create_history(name=shared_history_name) + history_id = new_history["id"] + logger.info(f"Created shared history: {history_id} ({shared_history_name})") + self._shared_history_id = history_id + return history_id + + except Exception as e: + logger.error(f"Error getting or creating shared history: {e}") + # Fallback to creating a new history with timestamp + import time + fallback_name = f"{shared_history_name} - {int(time.time())}" + logger.warning(f"Falling back to creating history: {fallback_name}") + fallback_history = self.gi.histories.create_history(name=fallback_name) + return fallback_history["id"] \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c9474e351..71a2f5a97 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,12 +8,13 @@ authors = [ readme = "README.md" requires-python = ">=3.12" dependencies = [ + "bioblend>=1.4.0", "fastapi>=0.116.1", - "uvicorn>=0.35.0", - "redis>=6.4.0", "httpx>=0.28.1", "pydantic>=2.11.7", - "python-dotenv>=1.1.1" + "python-dotenv>=1.1.1", + "redis>=6.4.0", + "uvicorn>=0.35.0" ] [project.optional-dependencies] diff --git a/pages/galaxy-test/index.tsx b/pages/galaxy-test/index.tsx new file mode 100644 index 000000000..f90de14b9 --- /dev/null +++ b/pages/galaxy-test/index.tsx @@ -0,0 +1,22 @@ +import { GetStaticProps } from "next"; +import { StyledPagesMain } from "../../app/components/Layout/components/Main/main.styles"; +import { GalaxyJobView } from "../../app/views/GalaxyJobView/galaxyJobView"; + +export const GalaxyJob = (): JSX.Element => { + return ; +}; + +export const getStaticProps: GetStaticProps = async () => { + return { + props: { + pageTitle: "Galaxy Integration Test", + themeOptions: { + palette: { background: { default: "#FAFBFB" } }, // SMOKE_LIGHTEST + }, + }, + }; +}; + +export default GalaxyJob; + +GalaxyJob.Main = StyledPagesMain; diff --git a/tests/e2e/09-galaxy-integration.spec.ts b/tests/e2e/09-galaxy-integration.spec.ts new file mode 100644 index 000000000..74a2d487e --- /dev/null +++ b/tests/e2e/09-galaxy-integration.spec.ts @@ -0,0 +1,337 @@ +import { test, expect } from "@playwright/test"; + +test.describe("BRC Analytics - Galaxy Integration", () => { + test("Galaxy service health check", async ({ request }) => { + // Check backend health first + const response = await request.get("http://localhost:8000/api/v1/health"); + expect(response.ok()).toBeTruthy(); + + const health = await response.json(); + console.log("Backend health:", health); + + // Check Galaxy-specific health endpoint + const galaxyResponse = await request.get( + "http://localhost:8000/api/v1/galaxy/health" + ); + expect(galaxyResponse.ok()).toBeTruthy(); + + const galaxyHealth = await galaxyResponse.json(); + console.log("Galaxy health:", galaxyHealth); + + expect(galaxyHealth.status).toBe("healthy"); + expect(galaxyHealth.galaxy_configured).toBe(true); + expect(galaxyHealth.api_url).toMatch( + /galaxyproject\.org|host\.docker\.internal|localhost/ + ); + + if (galaxyHealth.galaxy_configured) { + console.log("✅ Galaxy is configured and available"); + } else { + console.log("⚠️ Galaxy is not configured - skipping Galaxy tests"); + } + }); + + // eslint-disable-next-line sonarjs/cognitive-complexity -- Complex test that covers full Galaxy job lifecycle + test("Galaxy job submission and lifecycle", async ({ request }) => { + // First check if Galaxy is available + const healthResponse = await request.get( + "http://localhost:8000/api/v1/galaxy/health" + ); + const health = await healthResponse.json(); + + if (!health.galaxy_configured) { + console.log("Skipping Galaxy test - service not configured"); + // Skip remaining assertions if Galaxy is not configured + expect(health.galaxy_configured).toBe(false); + return; + } + + // Test data for Galaxy job submission + const testData = `header1\theader2\theader3 +sample1\tvalue1\tdata1 +sample2\tvalue2\tdata2 +sample3\tvalue3\tdata3 +sample4\tvalue4\tdata4 +sample5\tvalue5\tdata5 +sample6\tvalue6\tdata6 +sample7\tvalue7\tdata7 +sample8\tvalue8\tdata8 +sample9\tvalue9\tdata9 +sample10\tvalue10\tdata10`; + + // Step 1: Submit Galaxy job + console.log("🚀 Submitting Galaxy job..."); + const submitResponse = await request.post( + "http://localhost:8000/api/v1/galaxy/submit-job", + { + data: { + filename: "e2e-test-data.tsv", + num_random_lines: 3, + tabular_data: testData, + }, + } + ); + + expect(submitResponse.ok()).toBeTruthy(); + const submitResult = await submitResponse.json(); + console.log("Job submission result:", submitResult); + + expect(submitResult).toHaveProperty("job_id"); + expect(submitResult).toHaveProperty("upload_dataset_id"); + expect(submitResult.status).toBe("submitted"); + expect(submitResult.message).toContain("submitted successfully"); + + const jobId = submitResult.job_id; + console.log(`📝 Job ID: ${jobId}`); + + // Step 2: Poll job status + console.log("⏳ Polling job status..."); + let jobStatus; + let attempts = 0; + const maxAttempts = 30; // 5 minutes with 10-second intervals + + do { + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds + + const statusResponse = await request.get( + `http://localhost:8000/api/v1/galaxy/jobs/${jobId}/status` + ); + expect(statusResponse.ok()).toBeTruthy(); + + jobStatus = await statusResponse.json(); + console.log( + `📊 Job ${jobId} status: ${jobStatus.state} (attempt ${attempts + 1})` + ); + + expect(jobStatus).toHaveProperty("job_id", jobId); + expect(jobStatus).toHaveProperty("state"); + expect(jobStatus).toHaveProperty("is_complete"); + expect(jobStatus).toHaveProperty("is_successful"); + expect(jobStatus).toHaveProperty("created_time"); + expect(jobStatus).toHaveProperty("updated_time"); + + attempts++; + } while (!jobStatus.is_complete && attempts < maxAttempts); + + if (!jobStatus.is_complete) { + console.log(`⚠️ Job did not complete within ${maxAttempts} attempts`); + console.log(`Final status: ${jobStatus.state}`); + // Don't fail the test for slow Galaxy infrastructure + return; + } + + console.log(`✅ Job completed with state: ${jobStatus.state}`); + + // Step 3: Get job results (if successful) + if (jobStatus.is_successful) { + console.log("📥 Retrieving job results..."); + const resultsResponse = await request.get( + `http://localhost:8000/api/v1/galaxy/jobs/${jobId}/results` + ); + expect(resultsResponse.ok()).toBeTruthy(); + + const results = await resultsResponse.json(); + console.log("Job results:", results); + + expect(results).toHaveProperty("job_id", jobId); + expect(results).toHaveProperty("status"); + expect(results).toHaveProperty("outputs"); + expect(results).toHaveProperty("results"); + expect(results).toHaveProperty("created_time"); + expect(results).toHaveProperty("completed_time"); + + // Verify that we got some output data + if (results.outputs && results.outputs.length > 0) { + console.log(`📊 Job produced ${results.outputs.length} output(s)`); + + // Check that results contain actual data + const outputKeys = Object.keys(results.results); + expect(outputKeys.length).toBeGreaterThan(0); + + // Verify the random lines output contains 3 lines as requested + for (const outputName of outputKeys) { + const outputContent = results.results[outputName]; + if ( + typeof outputContent === "string" && + outputContent.includes("\t") + ) { + const lines = outputContent.trim().split("\n"); + console.log(`📄 Output '${outputName}' has ${lines.length} lines`); + + // Should have header + 3 random lines = 4 total lines + expect(lines.length).toBeLessThanOrEqual(4); + expect(lines.length).toBeGreaterThan(0); + } + } + } + } else { + console.log(`❌ Job failed with state: ${jobStatus.state}`); + if (jobStatus.stderr) { + console.log("Error output:", jobStatus.stderr); + } + } + }); + + test("Galaxy job error handling", async ({ request }) => { + // First check if Galaxy is available + const healthResponse = await request.get( + "http://localhost:8000/api/v1/galaxy/health" + ); + const health = await healthResponse.json(); + + if (!health.galaxy_configured) { + console.log("Skipping Galaxy error test - service not configured"); + // Skip remaining assertions if Galaxy is not configured + expect(health.galaxy_configured).toBe(false); + return; + } + + // Test 1: Invalid job submission (empty data) + console.log("🧪 Testing error handling for empty data..."); + const emptyDataResponse = await request.post( + "http://localhost:8000/api/v1/galaxy/submit-job", + { + data: { + filename: "empty.tsv", + num_random_lines: 5, + tabular_data: "", + }, + } + ); + + // Should either reject or handle gracefully + if (!emptyDataResponse.ok()) { + const error = await emptyDataResponse.json(); + console.log("Empty data error (expected):", error); + expect(emptyDataResponse.status()).toBeGreaterThanOrEqual(400); + } else { + console.log("Empty data was accepted (Galaxy may handle this)"); + } + + // Test 2: Non-existent job status + console.log("🧪 Testing error handling for non-existent job..."); + const fakeJobResponse = await request.get( + "http://localhost:8000/api/v1/galaxy/jobs/fake-job-id-12345/status" + ); + + expect(fakeJobResponse.status()).toBeGreaterThanOrEqual(400); + const fakeJobError = await fakeJobResponse.json(); + console.log("Fake job error (expected):", fakeJobError); + + // Test 3: Invalid job results request + console.log("🧪 Testing error handling for non-existent job results..."); + const fakeResultsResponse = await request.get( + "http://localhost:8000/api/v1/galaxy/jobs/fake-job-id-12345/results" + ); + + expect(fakeResultsResponse.status()).toBeGreaterThanOrEqual(400); + const fakeResultsError = await fakeResultsResponse.json(); + console.log("Fake results error (expected):", fakeResultsError); + }); + + test("Galaxy frontend integration", async ({ page }) => { + // Navigate to Galaxy test page + console.log("🌐 Testing Galaxy frontend integration..."); + await page.goto("http://localhost:3000/galaxy-test"); + + // Check that the page loads + await expect(page.locator("h1").first()).toContainText("Galaxy", { + timeout: 10000, + }); + + // Look for key UI elements + const form = page.locator("form"); + const textArea = page.getByLabel("Tabular Data (TSV format)"); + const submitButton = page.locator("button[type='submit']"); + + await expect(form).toBeVisible(); + await expect(textArea).toBeVisible(); + await expect(submitButton).toBeVisible(); + + // Check if there's a number input for random lines + const numberInput = page.locator("input[type='number']"); + if (await numberInput.isVisible()) { + console.log("✅ Random lines input found"); + } + + // Check for job history section + const historySection = page.locator("text=Job History"); + if (await historySection.isVisible()) { + console.log("✅ Job history section found"); + } + + // Take a screenshot of the Galaxy test page + await page.screenshot({ + fullPage: true, + path: "tests/screenshots/galaxy-test-page.png", + }); + + console.log("📸 Screenshot saved: galaxy-test-page.png"); + }); + + test("Galaxy frontend job submission simulation", async ({ page }) => { + // Navigate to Galaxy test page + await page.goto("http://localhost:3000/galaxy-test"); + + // Wait for page to load + await expect(page.locator("h1").first()).toContainText("Galaxy", { + timeout: 10000, + }); + + // Fill in test data + const testData = `gene_id\texpression\tfold_change +GENE001\t123.45\t2.1 +GENE002\t67.89\t-1.5 +GENE003\t234.56\t3.2 +GENE004\t45.67\t-0.8 +GENE005\t156.78\t1.9`; + + const textArea = page.getByLabel("Tabular Data (TSV format)"); + await textArea.fill(testData); + + // Set number of random lines + const numberInput = page.locator("input[type='number']"); + if (await numberInput.isVisible()) { + await numberInput.fill("3"); + } + + // Take screenshot before submission + await page.screenshot({ + path: "tests/screenshots/galaxy-test-before-submit.png", + }); + + // Submit the form (only if we can see the submit button) + const submitButton = page.locator("button[type='submit']"); + if ((await submitButton.isVisible()) && (await submitButton.isEnabled())) { + console.log("🚀 Submitting test job via frontend..."); + + // Click submit + await submitButton.click(); + + // Wait a moment for the UI to respond + await page.waitForTimeout(2000); + + // Look for success indicators + const successMessage = page.locator("text=submitted"); + const jobIdDisplay = page.locator("text=Job ID"); + + if ( + (await successMessage.isVisible()) || + (await jobIdDisplay.isVisible()) + ) { + console.log("✅ Job submission appears successful in UI"); + + // Take screenshot after submission + await page.screenshot({ + path: "tests/screenshots/galaxy-test-after-submit.png", + }); + } else { + console.log( + "ℹ️ No immediate success indicators found (may be loading)" + ); + } + } else { + console.log("ℹ️ Submit button not available or disabled"); + } + }); +}); From d5f276606d4becc4833a19413ba7e6b191ced19f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 29 Sep 2025 22:52:34 -0400 Subject: [PATCH 8/9] chore: update README for Galaxy branch and sync .gitignore - Update README to describe Galaxy integration only (not LLM/ENA) - Sync .gitignore from infrastructure branch --- backend/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 0d8cb8ade..5f64dedc3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # BRC Analytics Backend -FastAPI backend infrastructure for BRC Analytics. +FastAPI backend infrastructure for BRC Analytics with Galaxy integration. ## Features @@ -9,6 +9,7 @@ FastAPI backend infrastructure for BRC Analytics. - Health check endpoints - Docker deployment with nginx reverse proxy - uv for dependency management +- Galaxy workflow execution via BioBLEND ## Quick Start From 624e82e34f235e356b7da4951dcd27c1cb67718e Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 3 Oct 2025 17:12:36 -0400 Subject: [PATCH 9/9] chore: regenerate uv.lock with bioblend dependencies --- backend/uv.lock | 491 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 491 insertions(+) diff --git a/backend/uv.lock b/backend/uv.lock index a96f5410f..f3282985f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2,6 +2,79 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -25,11 +98,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "bioblend" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "tuspy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/bf/1a919605eb2644c04c336ed87eb5e4cf00399cdf983496d27105649544c3/bioblend-1.6.0.tar.gz", hash = "sha256:b787f1ee30407645b0a292a1adbb96ac0880b54455b612af1bb6123ec9aa8739", size = 157200, upload-time = "2025-06-18T22:07:01.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/19/529e6ade85e681cb84d5e5c6501f2492f17ec2c8f8fe246a3898ffcb0e2c/bioblend-1.6.0-py3-none-any.whl", hash = "sha256:7127e7b654d75595836d7910a197f690f11af2eed94dea05b440ee3b03cc5f86", size = 163715, upload-time = "2025-06-18T22:06:59.653Z" }, +] + [[package]] name = "brc-analytics-backend" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "bioblend" }, { name = "fastapi" }, { name = "httpx" }, { name = "pydantic" }, @@ -47,6 +145,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "bioblend", specifier = ">=1.4.0" }, { name = "fastapi", specifier = ">=0.116.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.11.7" }, @@ -68,6 +167,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -103,6 +244,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/54e2bdaad22ca91a59455251998d43094d5c3d3567c52c7c04774b3f43f2/fastapi-0.118.0-py3-none-any.whl", hash = "sha256:705137a61e2ef71019d2445b123aa8845bd97273c395b744d5a7dfe559056855", size = 97694, upload-time = "2025-09-29T03:37:21.338Z" }, ] +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -158,6 +359,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -176,6 +440,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + [[package]] name = "pydantic" version = "2.11.9" @@ -280,6 +601,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "redis" version = "6.4.0" @@ -289,6 +656,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + [[package]] name = "ruff" version = "0.13.3" @@ -337,6 +731,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] +[[package]] +name = "tinydb" +version = "4.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/79/4af51e2bb214b6ea58f857c51183d92beba85b23f7ba61c983ab3de56c33/tinydb-4.8.2.tar.gz", hash = "sha256:f7dfc39b8d7fda7a1ca62a8dbb449ffd340a117c1206b68c50b1a481fb95181d", size = 32566, upload-time = "2024-10-12T15:24:01.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/17/853354204e1ca022d6b7d011ca7f3206c4f8faa3cc743e92609b49c1d83f/tinydb-4.8.2-py3-none-any.whl", hash = "sha256:f97030ee5cbc91eeadd1d7af07ab0e48ceb04aa63d4a983adbaca4cba16e86c3", size = 24888, upload-time = "2024-10-12T15:23:59.833Z" }, +] + +[[package]] +name = "tuspy" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, + { name = "tinydb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/21/706996ed872ecb6881dec3708cfbc6a3f92026e9e62acc9fcc4bc1019f2d/tuspy-1.1.0.tar.gz", hash = "sha256:156734eac5c61a046cfecd70f14119f05be92cce198eb5a1a99a664482bedb89", size = 16949, upload-time = "2024-11-29T11:43:55.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/8e/b94ec25fcd4384de2a1f5e0403284f005ec743c04bafdb6698c851df5c6d/tuspy-1.1.0-py3-none-any.whl", hash = "sha256:7fc5ac8fb25de37c96c90213f83a1ffdede7f48a471cb5a15a2f57846828a79a", size = 15319, upload-time = "2024-11-29T11:43:47.072Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -358,6 +775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "uvicorn" version = "0.37.0" @@ -370,3 +796,68 @@ sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d80 wheels = [ { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, ] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +]