commit da496746bb35c1ae607da7489d4acaba69d141c3 Author: zanewalker Date: Fri Jan 9 00:26:19 2026 +0000 Initial commit: fastapi-traffic rate limiting library - Core rate limiting with multiple algorithms (sliding window, token bucket, etc.) - SQLite and memory backends - Decorator and dependency injection patterns - Middleware support - Example usage files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.vscode/sessions.json b/.vscode/sessions.json new file mode 100644 index 0000000..00c534b --- /dev/null +++ b/.vscode/sessions.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://cdn.statically.io/gh/nguyenngoclongdev/cdn/main/schema/v11/terminal-keeper.json", + "theme": "tribe", + "active": "default", + "activateOnStartup": true, + "keepExistingTerminals": false, + "sessions": { + "default": [ + { + "name": "hello", + "autoExecuteCommands": true, + "icon": "person", + "color": "terminal.ansiGreen", + "commands": [ + "echo hello" + ] + }, + [ + { + "name": "docker:ros", + "commands": [ + "" + ] + }, + { + "name": "docker:k8s", + "commands": [ + "" + ] + } + ], + [ + { + "name": "docker:nats", + "commands": [ + "" + ] + }, + { + "name": "docker:fleet", + "commands": [ + "" + ] + } + ] + ], + "saved-session": [ + { + "name": "connect", + "commands": [ + "" + ] + } + ] + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2a7015 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# FastAPI Traffic + +Production-grade rate limiting for FastAPI with multiple algorithms and storage backends. + +## Features + +- **Multiple Algorithms**: Token Bucket, Sliding Window, Fixed Window, Leaky Bucket, Sliding Window Counter +- **Multiple Backends**: In-memory (default), SQLite (persistent), Redis (distributed) +- **Decorator-based**: Simple `@rate_limit` decorator for endpoints +- **Middleware Support**: Global rate limiting via middleware +- **Dependency Injection**: Use as FastAPI dependency for more control +- **Strict Typing**: Full type hints, pyright/mypy compatible +- **Customizable**: Custom key extractors, exemptions, callbacks +- **Production Ready**: Connection pooling, async support, proper error handling + +## Installation + +```bash +# Basic installation (memory backend only) +pip install fastapi-traffic + +# With Redis support +pip install fastapi-traffic[redis] + +# With all extras +pip install fastapi-traffic[all] +``` + +## Quick Start + +### Basic Usage with Decorator + +```python +from fastapi import FastAPI, Request +from fastapi_traffic import rate_limit + +app = FastAPI() + +@app.get("/api/resource") +@rate_limit(100, 60) # 100 requests per 60 seconds +async def get_resource(request: Request): + return {"message": "Hello, World!"} +``` + +### Using Different Algorithms + +```python +from fastapi_traffic import rate_limit, Algorithm + +# Token Bucket - allows bursts +@app.get("/api/burst") +@rate_limit(100, 60, algorithm=Algorithm.TOKEN_BUCKET, burst_size=20) +async def burst_endpoint(request: Request): + return {"message": "Burst allowed"} + +# Sliding Window - precise rate limiting +@app.get("/api/precise") +@rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW) +async def precise_endpoint(request: Request): + return {"message": "Precise limiting"} + +# Fixed Window - simple and efficient +@app.get("/api/simple") +@rate_limit(100, 60, algorithm=Algorithm.FIXED_WINDOW) +async def simple_endpoint(request: Request): + return {"message": "Fixed window"} +``` + +### Custom Key Extraction + +```python +def api_key_extractor(request: Request) -> str: + """Rate limit by API key instead of IP.""" + return request.headers.get("X-API-Key", "anonymous") + +@app.get("/api/by-key") +@rate_limit(1000, 3600, key_extractor=api_key_extractor) +async def api_key_endpoint(request: Request): + return {"message": "Rate limited by API key"} +``` + +### Using SQLite Backend (Persistent) + +```python +from fastapi_traffic import RateLimiter, SQLiteBackend +from fastapi_traffic.core.limiter import set_limiter + +# Configure persistent storage +backend = SQLiteBackend("rate_limits.db") +limiter = RateLimiter(backend) +set_limiter(limiter) + +@app.on_event("startup") +async def startup(): + await limiter.initialize() + +@app.on_event("shutdown") +async def shutdown(): + await limiter.close() +``` + +### Using Redis Backend (Distributed) + +```python +from fastapi_traffic import RateLimiter +from fastapi_traffic.backends.redis import RedisBackend + +# Create Redis backend +backend = await RedisBackend.from_url("redis://localhost:6379/0") +limiter = RateLimiter(backend) +set_limiter(limiter) +``` + +### Global Middleware + +```python +from fastapi_traffic.middleware import RateLimitMiddleware + +app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_paths={"/health", "/docs"}, + exempt_ips={"127.0.0.1"}, +) +``` + +### Dependency Injection + +```python +from fastapi import Depends +from fastapi_traffic.core.decorator import RateLimitDependency + +rate_dep = RateLimitDependency(limit=100, window_size=60) + +@app.get("/api/with-info") +async def endpoint_with_info( + request: Request, + rate_info = Depends(rate_dep) +): + return { + "remaining": rate_info.remaining, + "reset_at": rate_info.reset_at, + } +``` + +### Exception Handling + +```python +from fastapi_traffic import RateLimitExceeded + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "retry_after": exc.retry_after, + }, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) +``` + +## Algorithms + +| Algorithm | Description | Use Case | +|-----------|-------------|----------| +| `TOKEN_BUCKET` | Allows bursts up to bucket capacity | APIs that need burst handling | +| `SLIDING_WINDOW` | Precise request counting | High-accuracy rate limiting | +| `FIXED_WINDOW` | Simple time-based windows | Simple, low-overhead limiting | +| `LEAKY_BUCKET` | Smooths out request rate | Consistent throughput | +| `SLIDING_WINDOW_COUNTER` | Balance of precision and efficiency | General purpose (default) | + +## Backends + +### MemoryBackend (Default) +- In-memory storage with LRU eviction +- Best for single-process applications +- No persistence across restarts + +### SQLiteBackend +- Persistent storage using SQLite +- WAL mode for better performance +- Suitable for single-node deployments + +### RedisBackend +- Distributed storage using Redis +- Required for multi-node deployments +- Supports atomic operations via Lua scripts + +## Configuration Options + +```python +@rate_limit( + limit=100, # Max requests in window + window_size=60.0, # Window size in seconds + algorithm=Algorithm.SLIDING_WINDOW_COUNTER, + key_prefix="api", # Prefix for rate limit keys + key_extractor=func, # Custom key extraction function + burst_size=None, # Burst size (token/leaky bucket) + include_headers=True, # Add rate limit headers to response + error_message="...", # Custom error message + status_code=429, # HTTP status when limited + skip_on_error=False, # Skip limiting on backend errors + cost=1, # Cost per request + exempt_when=func, # Function to check exemption + on_blocked=func, # Callback when request is blocked +) +``` + +## Response Headers + +When `include_headers=True`, responses include: + +- `X-RateLimit-Limit`: Maximum requests allowed +- `X-RateLimit-Remaining`: Remaining requests in window +- `X-RateLimit-Reset`: Unix timestamp when limit resets +- `Retry-After`: Seconds until retry (when rate limited) + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Type checking +pyright + +# Linting +ruff check . +ruff format . +``` + +## License + +MIT License diff --git a/examples/01_quickstart.py b/examples/01_quickstart.py new file mode 100644 index 0000000..8fa2eb4 --- /dev/null +++ b/examples/01_quickstart.py @@ -0,0 +1,60 @@ +"""Quickstart example - minimal setup to get rate limiting working.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter + +# Step 1: Create a backend and limiter +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan context manager for startup/shutdown.""" + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Quickstart Example", lifespan=lifespan) + + +# Step 2: Add exception handler for rate limit errors +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={"error": "Too many requests", "retry_after": exc.retry_after}, + ) + + +# Step 3: Apply rate limiting to endpoints +@app.get("/") +@rate_limit(10, 60) # 10 requests per minute +async def hello(request: Request) -> dict[str, str]: + return {"message": "Hello, World!"} + + +@app.get("/api/data") +@rate_limit(100, 60) # 100 requests per minute +async def get_data(request: Request) -> dict[str, str]: + return {"data": "Some important data"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/02_algorithms.py b/examples/02_algorithms.py new file mode 100644 index 0000000..1e98c2e --- /dev/null +++ b/examples/02_algorithms.py @@ -0,0 +1,122 @@ +"""Examples demonstrating all available rate limiting algorithms.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + Algorithm, + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Rate Limiting Algorithms", lifespan=lifespan) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) + + +# 1. Fixed Window - Simple, resets at fixed intervals +# Best for: Simple use cases, low memory usage +# Drawback: Can allow 2x burst at window boundaries +@app.get("/fixed-window") +@rate_limit( + limit=10, + window_size=60, + algorithm=Algorithm.FIXED_WINDOW, +) +async def fixed_window(request: Request) -> dict[str, str]: + """Fixed window resets counter at fixed time intervals.""" + return {"algorithm": "fixed_window", "description": "Counter resets every 60 seconds"} + + +# 2. Sliding Window Log - Most precise +# Best for: When accuracy is critical +# Drawback: Higher memory usage (stores all timestamps) +@app.get("/sliding-window") +@rate_limit( + limit=10, + window_size=60, + algorithm=Algorithm.SLIDING_WINDOW, +) +async def sliding_window(request: Request) -> dict[str, str]: + """Sliding window tracks exact timestamps for precise limiting.""" + return {"algorithm": "sliding_window", "description": "Precise tracking with timestamp log"} + + +# 3. Sliding Window Counter - Balance of precision and efficiency +# Best for: Most production use cases (default algorithm) +# Combines benefits of fixed window efficiency with sliding window precision +@app.get("/sliding-window-counter") +@rate_limit( + limit=10, + window_size=60, + algorithm=Algorithm.SLIDING_WINDOW_COUNTER, +) +async def sliding_window_counter(request: Request) -> dict[str, str]: + """Sliding window counter uses weighted counts from current and previous windows.""" + return {"algorithm": "sliding_window_counter", "description": "Efficient approximation"} + + +# 4. Token Bucket - Allows controlled bursts +# Best for: APIs that need to allow occasional bursts +# Tokens refill gradually, burst_size controls max burst +@app.get("/token-bucket") +@rate_limit( + limit=10, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=5, # Allow bursts of up to 5 requests +) +async def token_bucket(request: Request) -> dict[str, str]: + """Token bucket allows bursts up to burst_size, then refills gradually.""" + return {"algorithm": "token_bucket", "description": "Allows controlled bursts"} + + +# 5. Leaky Bucket - Smooths out traffic +# Best for: Protecting downstream services from bursts +# Processes requests at a constant rate +@app.get("/leaky-bucket") +@rate_limit( + limit=10, + window_size=60, + algorithm=Algorithm.LEAKY_BUCKET, + burst_size=5, # Queue capacity +) +async def leaky_bucket(request: Request) -> dict[str, str]: + """Leaky bucket smooths traffic to a constant rate.""" + return {"algorithm": "leaky_bucket", "description": "Constant output rate"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/03_backends.py b/examples/03_backends.py new file mode 100644 index 0000000..418efe8 --- /dev/null +++ b/examples/03_backends.py @@ -0,0 +1,108 @@ +"""Examples demonstrating different storage backends.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + MemoryBackend, + RateLimitExceeded, + RateLimiter, + SQLiteBackend, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter + + +# Choose backend based on environment +def get_backend(): + """Select appropriate backend based on environment.""" + backend_type = os.getenv("RATE_LIMIT_BACKEND", "memory") + + if backend_type == "sqlite": + # SQLite - Good for single-instance apps, persists across restarts + return SQLiteBackend("rate_limits.db") + + elif backend_type == "redis": + # Redis - Required for distributed/multi-instance deployments + # Requires: pip install redis + try: + from fastapi_traffic import RedisBackend + import asyncio + + async def create_redis(): + return await RedisBackend.from_url( + os.getenv("REDIS_URL", "redis://localhost:6379/0"), + key_prefix="myapp_ratelimit", + ) + + return asyncio.get_event_loop().run_until_complete(create_redis()) + except ImportError: + print("Redis not installed, falling back to memory backend") + return MemoryBackend() + + else: + # Memory - Fast, but resets on restart, not shared across instances + return MemoryBackend() + + +backend = get_backend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Storage Backends Example", lifespan=lifespan) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after}, + ) + + +@app.get("/api/resource") +@rate_limit(100, 60) +async def get_resource(request: Request) -> dict[str, str]: + return {"message": "Resource data", "backend": type(backend).__name__} + + +@app.get("/backend-info") +async def backend_info() -> dict[str, Any]: + """Get information about the current backend.""" + info = { + "backend_type": type(backend).__name__, + "description": "", + } + + if isinstance(backend, MemoryBackend): + info["description"] = "In-memory storage, fast but ephemeral" + elif isinstance(backend, SQLiteBackend): + info["description"] = "SQLite storage, persistent, single-instance" + else: + info["description"] = "Redis storage, distributed, multi-instance" + + return info + + +if __name__ == "__main__": + import uvicorn + + # Run with different backends: + # RATE_LIMIT_BACKEND=memory python 03_backends.py + # RATE_LIMIT_BACKEND=sqlite python 03_backends.py + # RATE_LIMIT_BACKEND=redis REDIS_URL=redis://localhost:6379/0 python 03_backends.py + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/04_key_extractors.py b/examples/04_key_extractors.py new file mode 100644 index 0000000..2a01e7b --- /dev/null +++ b/examples/04_key_extractors.py @@ -0,0 +1,153 @@ +"""Examples demonstrating custom key extractors for rate limiting.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Custom Key Extractors", lifespan=lifespan) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after}, + ) + + +# 1. Default: Rate limit by IP address +@app.get("/by-ip") +@rate_limit(10, 60) # Uses default IP-based key extractor +async def by_ip(request: Request) -> dict[str, str]: + """Rate limited by client IP address (default behavior).""" + return {"limited_by": "ip", "client_ip": request.client.host if request.client else "unknown"} + + +# 2. Rate limit by API key +def api_key_extractor(request: Request) -> str: + """Extract API key from header.""" + api_key = request.headers.get("X-API-Key", "anonymous") + return f"api_key:{api_key}" + + +@app.get("/by-api-key") +@rate_limit( + limit=100, + window_size=3600, # 100 requests per hour per API key + key_extractor=api_key_extractor, +) +async def by_api_key(request: Request) -> dict[str, str]: + """Rate limited by API key from X-API-Key header.""" + api_key = request.headers.get("X-API-Key", "anonymous") + return {"limited_by": "api_key", "api_key": api_key} + + +# 3. Rate limit by user ID (from JWT or session) +def user_id_extractor(request: Request) -> str: + """Extract user ID from request state or header.""" + # In real apps, this would come from decoded JWT or session + user_id = request.headers.get("X-User-ID", "anonymous") + return f"user:{user_id}" + + +@app.get("/by-user") +@rate_limit( + limit=50, + window_size=60, + key_extractor=user_id_extractor, +) +async def by_user(request: Request) -> dict[str, str]: + """Rate limited by user ID.""" + user_id = request.headers.get("X-User-ID", "anonymous") + return {"limited_by": "user_id", "user_id": user_id} + + +# 4. Rate limit by endpoint + IP (separate limits per endpoint) +def endpoint_ip_extractor(request: Request) -> str: + """Combine endpoint path with IP for per-endpoint limits.""" + ip = request.client.host if request.client else "unknown" + path = request.url.path + return f"endpoint:{path}:ip:{ip}" + + +@app.get("/endpoint-specific") +@rate_limit( + limit=5, + window_size=60, + key_extractor=endpoint_ip_extractor, +) +async def endpoint_specific(request: Request) -> dict[str, str]: + """Each endpoint has its own rate limit counter.""" + return {"limited_by": "endpoint+ip"} + + +# 5. Rate limit by organization/tenant (multi-tenant apps) +def tenant_extractor(request: Request) -> str: + """Extract tenant from subdomain or header.""" + # Could also parse from subdomain: tenant.example.com + tenant = request.headers.get("X-Tenant-ID", "default") + return f"tenant:{tenant}" + + +@app.get("/by-tenant") +@rate_limit( + limit=1000, + window_size=3600, # 1000 requests per hour per tenant + key_extractor=tenant_extractor, +) +async def by_tenant(request: Request) -> dict[str, str]: + """Rate limited by tenant/organization.""" + tenant = request.headers.get("X-Tenant-ID", "default") + return {"limited_by": "tenant", "tenant_id": tenant} + + +# 6. Composite key: User + Action type +def user_action_extractor(request: Request) -> str: + """Rate limit specific actions per user.""" + user_id = request.headers.get("X-User-ID", "anonymous") + action = request.query_params.get("action", "default") + return f"user:{user_id}:action:{action}" + + +@app.get("/user-action") +@rate_limit( + limit=10, + window_size=60, + key_extractor=user_action_extractor, +) +async def user_action( + request: Request, + action: str = "default", +) -> dict[str, str]: + """Rate limited by user + action combination.""" + user_id = request.headers.get("X-User-ID", "anonymous") + return {"limited_by": "user+action", "user_id": user_id, "action": action} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/05_middleware.py b/examples/05_middleware.py new file mode 100644 index 0000000..98acec8 --- /dev/null +++ b/examples/05_middleware.py @@ -0,0 +1,109 @@ +"""Examples demonstrating middleware-based rate limiting.""" + +from __future__ import annotations + +from fastapi import FastAPI, Request + +from fastapi_traffic import MemoryBackend +from fastapi_traffic.middleware import RateLimitMiddleware + +# Alternative middleware options (uncomment to use): +# from fastapi_traffic.middleware import SlidingWindowMiddleware +# from fastapi_traffic.middleware import TokenBucketMiddleware + +app = FastAPI(title="Middleware Rate Limiting") + + +# Custom key extractor for middleware +def get_client_identifier(request: Request) -> str: + """Extract client identifier from request.""" + # Check for API key first + api_key = request.headers.get("X-API-Key") + if api_key: + return f"api_key:{api_key}" + + # Fall back to IP + if request.client: + return f"ip:{request.client.host}" + + return "unknown" + + +# Option 1: Basic middleware with defaults +# Uncomment to use: +# app.add_middleware( +# RateLimitMiddleware, +# limit=100, +# window_size=60, +# ) + +# Option 2: Middleware with custom configuration +app.add_middleware( + RateLimitMiddleware, + limit=100, + window_size=60, + backend=MemoryBackend(), + key_prefix="global", + include_headers=True, + error_message="You have exceeded the rate limit. Please slow down.", + status_code=429, + skip_on_error=True, # Don't block requests if backend fails + exempt_paths={"/health", "/docs", "/openapi.json", "/redoc"}, + exempt_ips={"127.0.0.1"}, # Exempt localhost + key_extractor=get_client_identifier, +) + + +# Option 3: Convenience middleware for specific algorithms +# SlidingWindowMiddleware - precise rate limiting +# app.add_middleware( +# SlidingWindowMiddleware, +# limit=100, +# window_size=60, +# ) + +# TokenBucketMiddleware - allows bursts +# app.add_middleware( +# TokenBucketMiddleware, +# limit=100, +# window_size=60, +# ) + + +@app.get("/") +async def root() -> dict[str, str]: + """Root endpoint - rate limited by middleware.""" + return {"message": "Hello, World!"} + + +@app.get("/api/data") +async def get_data() -> dict[str, str]: + """API endpoint - rate limited by middleware.""" + return {"data": "Some important data"} + + +@app.get("/api/users") +async def get_users() -> dict[str, list[str]]: + """Users endpoint - rate limited by middleware.""" + return {"users": ["alice", "bob", "charlie"]} + + +@app.get("/health") +async def health() -> dict[str, str]: + """Health check - exempt from rate limiting.""" + return {"status": "healthy"} + + +@app.get("/docs-info") +async def docs_info() -> dict[str, str]: + """Info about documentation endpoints.""" + return { + "message": "Visit /docs for Swagger UI or /redoc for ReDoc", + "note": "These endpoints are exempt from rate limiting", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/06_dependency_injection.py b/examples/06_dependency_injection.py new file mode 100644 index 0000000..2d211ee --- /dev/null +++ b/examples/06_dependency_injection.py @@ -0,0 +1,221 @@ +"""Examples demonstrating rate limiting with FastAPI dependency injection.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + MemoryBackend, + RateLimitExceeded, + RateLimiter, +) +from fastapi_traffic.core.decorator import RateLimitDependency +from fastapi_traffic.core.limiter import set_limiter + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan context manager for startup/shutdown.""" + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Dependency Injection Example", lifespan=lifespan) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after}, + ) + + +# 1. Basic dependency - rate limit info available in endpoint +basic_rate_limit = RateLimitDependency(limit=10, window_size=60) + + +@app.get("/basic") +async def basic_endpoint( + request: Request, + rate_info: Any = Depends(basic_rate_limit), +) -> dict[str, Any]: + """Access rate limit info in your endpoint logic.""" + return { + "message": "Success", + "rate_limit": { + "limit": rate_info.limit, + "remaining": rate_info.remaining, + "reset_at": rate_info.reset_at, + }, + } + + +# 2. Different limits for different user tiers +def get_user_tier(request: Request) -> str: + """Get user tier from header (in real app, from JWT/database).""" + return request.headers.get("X-User-Tier", "free") + + +free_tier_limit = RateLimitDependency( + limit=10, + window_size=60, + key_prefix="free", +) + +pro_tier_limit = RateLimitDependency( + limit=100, + window_size=60, + key_prefix="pro", +) + +enterprise_tier_limit = RateLimitDependency( + limit=1000, + window_size=60, + key_prefix="enterprise", +) + + +async def tiered_rate_limit( + request: Request, + tier: str = Depends(get_user_tier), +) -> Any: + """Apply different rate limits based on user tier.""" + if tier == "enterprise": + return await enterprise_tier_limit(request) + elif tier == "pro": + return await pro_tier_limit(request) + else: + return await free_tier_limit(request) + + +@app.get("/tiered") +async def tiered_endpoint( + request: Request, + rate_info: Any = Depends(tiered_rate_limit), +) -> dict[str, Any]: + """Endpoint with tier-based rate limiting.""" + tier = get_user_tier(request) + return { + "message": "Success", + "tier": tier, + "rate_limit": { + "limit": rate_info.limit, + "remaining": rate_info.remaining, + }, + } + + +# 3. Conditional rate limiting based on request properties +def api_key_extractor(request: Request) -> str: + """Extract API key for rate limiting.""" + api_key = request.headers.get("X-API-Key", "anonymous") + return f"api:{api_key}" + + +api_rate_limit = RateLimitDependency( + limit=100, + window_size=3600, + key_extractor=api_key_extractor, +) + + +@app.get("/api/resource") +async def api_resource( + request: Request, + rate_info: Any = Depends(api_rate_limit), +) -> dict[str, Any]: + """API endpoint with per-API-key rate limiting.""" + return { + "data": "Resource data", + "requests_remaining": rate_info.remaining, + } + + +# 4. Combine multiple rate limits (e.g., per-minute AND per-hour) +per_minute_limit = RateLimitDependency( + limit=10, + window_size=60, + key_prefix="minute", +) + +per_hour_limit = RateLimitDependency( + limit=100, + window_size=3600, + key_prefix="hour", +) + + +async def combined_rate_limit( + request: Request, + minute_info: Any = Depends(per_minute_limit), + hour_info: Any = Depends(per_hour_limit), +) -> dict[str, Any]: + """Apply both per-minute and per-hour limits.""" + return { + "minute": { + "limit": minute_info.limit, + "remaining": minute_info.remaining, + }, + "hour": { + "limit": hour_info.limit, + "remaining": hour_info.remaining, + }, + } + + +@app.get("/combined") +async def combined_endpoint( + request: Request, + rate_info: dict[str, Any] = Depends(combined_rate_limit), +) -> dict[str, Any]: + """Endpoint with multiple rate limit tiers.""" + return { + "message": "Success", + "rate_limits": rate_info, + } + + +# 5. Rate limit with custom exemption logic +def is_internal_request(request: Request) -> bool: + """Check if request is from internal service.""" + internal_token = request.headers.get("X-Internal-Token") + return internal_token == "internal-secret-token" + + +internal_exempt_limit = RateLimitDependency( + limit=10, + window_size=60, + exempt_when=is_internal_request, +) + + +@app.get("/internal-exempt") +async def internal_exempt_endpoint( + request: Request, + rate_info: Any = Depends(internal_exempt_limit), +) -> dict[str, Any]: + """Internal requests are exempt from rate limiting.""" + is_internal = is_internal_request(request) + return { + "message": "Success", + "is_internal": is_internal, + "rate_limit": None if is_internal else { + "remaining": rate_info.remaining, + }, + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/07_redis_distributed.py b/examples/07_redis_distributed.py new file mode 100644 index 0000000..ec7f156 --- /dev/null +++ b/examples/07_redis_distributed.py @@ -0,0 +1,197 @@ +"""Example demonstrating Redis backend for distributed rate limiting. + +This example shows how to use Redis for rate limiting across multiple +application instances (e.g., in a Kubernetes deployment or load-balanced setup). + +Requirements: + pip install redis + +Environment variables: + REDIS_URL: Redis connection URL (default: redis://localhost:6379/0) +""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse +from typing import Annotated + +from fastapi_traffic import ( + Algorithm, + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter +from fastapi_traffic.backends.redis import RedisBackend + + +async def create_redis_backend(): + """Create Redis backend with fallback to memory.""" + try: + from fastapi_traffic import RedisBackend + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + backend = await RedisBackend.from_url( + redis_url, + key_prefix="myapp", + ) + + # Verify connection + if await backend.ping(): + print(f"Connected to Redis at {redis_url}") + return backend + else: + print("Redis ping failed, falling back to memory backend") + return MemoryBackend() + + except ImportError: + print("Redis package not installed. Install with: pip install redis") + print("Falling back to memory backend") + return MemoryBackend() + + except Exception as e: + print(f"Failed to connect to Redis: {e}") + print("Falling back to memory backend") + return MemoryBackend() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifespan context manager for startup/shutdown.""" + app.state.backend = await create_redis_backend() + app.state.limiter = RateLimiter(app.state.backend) + await app.state.limiter.initialize() + set_limiter(app.state.limiter) + + yield + + await app.state.limiter.close() + + +app = FastAPI( + title="Distributed Rate Limiting with Redis", + lifespan=lifespan, +) + + +def get_backend(request: Request) -> RedisBackend | MemoryBackend: + """Dependency to get the rate limiting backend.""" + return request.app.state.backend + + +def get_limiter(request: Request) -> RateLimiter: + """Dependency to get the rate limiter.""" + return request.app.state.limiter + + +BackendDep = Annotated[RedisBackend | MemoryBackend, Depends(get_backend)] +LimiterDep = Annotated[RateLimiter, Depends(get_limiter)] + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) + + +# Rate limits are shared across all instances when using Redis +@app.get("/api/shared-limit") +@rate_limit( + limit=100, + window_size=60, + key_prefix="shared", +) +async def shared_limit(request: Request) -> dict[str, str]: + """This rate limit is shared across all application instances.""" + return { + "message": "Success", + "note": "Rate limit counter is shared via Redis", + } + + +# Per-user limits also work across instances +def user_extractor(request: Request) -> str: + user_id = request.headers.get("X-User-ID", "anonymous") + return f"user:{user_id}" + + +@app.get("/api/user-limit") +@rate_limit( + limit=50, + window_size=60, + key_extractor=user_extractor, + key_prefix="user_api", +) +async def user_limit(request: Request) -> dict[str, str]: + """Per-user rate limit shared across instances.""" + user_id = request.headers.get("X-User-ID", "anonymous") + return { + "message": "Success", + "user_id": user_id, + } + + +# Token bucket works well with Redis for burst handling +@app.get("/api/burst-allowed") +@rate_limit( + limit=100, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=20, + key_prefix="burst", +) +async def burst_allowed(request: Request) -> dict[str, str]: + """Token bucket with Redis allows controlled bursts across instances.""" + return {"message": "Burst request successful"} + + +@app.get("/health") +async def health(backend: BackendDep) -> dict[str, object]: + """Health check with Redis status.""" + redis_healthy = False + backend_type = type(backend).__name__ + + if hasattr(backend, "ping"): + try: + redis_healthy = await backend.ping() + except Exception: + redis_healthy = False + + return { + "status": "healthy", + "backend": backend_type, + "redis_connected": redis_healthy, + } + + +@app.get("/stats") +async def stats(backend: BackendDep) -> dict[str, object]: + """Get rate limiting statistics from Redis.""" + if hasattr(backend, "get_stats"): + try: + return await backend.get_stats() + except Exception as e: + return {"error": str(e)} + return {"message": "Stats not available for this backend"} + + +if __name__ == "__main__": + import uvicorn + + # Run multiple instances on different ports to test distributed limiting: + # REDIS_URL=redis://localhost:6379/0 python 07_redis_distributed.py + # In another terminal: + # uvicorn 07_redis_distributed:app --port 8001 + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/08_tiered_api.py b/examples/08_tiered_api.py new file mode 100644 index 0000000..70f8646 --- /dev/null +++ b/examples/08_tiered_api.py @@ -0,0 +1,256 @@ +"""Example of a production-ready tiered API with different rate limits per plan.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + Algorithm, + MemoryBackend, + RateLimitExceeded, + RateLimiter, +) +from fastapi_traffic.core.decorator import RateLimitDependency +from fastapi_traffic.core.limiter import set_limiter + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI( + title="Tiered API Example", + description="API with different rate limits based on subscription tier", + lifespan=lifespan, +) + + +class Tier(str, Enum): + FREE = "free" + STARTER = "starter" + PRO = "pro" + ENTERPRISE = "enterprise" + + +@dataclass +class TierConfig: + requests_per_minute: int + requests_per_hour: int + requests_per_day: int + burst_size: int + features: list[str] + + +# Tier configurations +TIER_CONFIGS: dict[Tier, TierConfig] = { + Tier.FREE: TierConfig( + requests_per_minute=10, + requests_per_hour=100, + requests_per_day=500, + burst_size=5, + features=["basic_api"], + ), + Tier.STARTER: TierConfig( + requests_per_minute=60, + requests_per_hour=1000, + requests_per_day=10000, + burst_size=20, + features=["basic_api", "webhooks"], + ), + Tier.PRO: TierConfig( + requests_per_minute=300, + requests_per_hour=10000, + requests_per_day=100000, + burst_size=50, + features=["basic_api", "webhooks", "analytics", "priority_support"], + ), + Tier.ENTERPRISE: TierConfig( + requests_per_minute=1000, + requests_per_hour=50000, + requests_per_day=500000, + burst_size=200, + features=["basic_api", "webhooks", "analytics", "priority_support", "sla", "custom_integrations"], + ), +} + + +# Simulated API key database +API_KEYS: dict[str, dict[str, Any]] = { + "free-key-123": {"tier": Tier.FREE, "user_id": "user_1"}, + "starter-key-456": {"tier": Tier.STARTER, "user_id": "user_2"}, + "pro-key-789": {"tier": Tier.PRO, "user_id": "user_3"}, + "enterprise-key-000": {"tier": Tier.ENTERPRISE, "user_id": "user_4"}, +} + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + api_key = request.headers.get("X-API-Key", "") + key_info = API_KEYS.get(api_key, {}) + tier = key_info.get("tier", Tier.FREE) + + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + "tier": tier.value, + "upgrade_url": "https://example.com/pricing" if tier != Tier.ENTERPRISE else None, + }, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) + + +def get_api_key_info(request: Request) -> dict[str, Any]: + """Validate API key and return info.""" + api_key = request.headers.get("X-API-Key") + if not api_key: + raise HTTPException(status_code=401, detail="API key required") + + key_info = API_KEYS.get(api_key) + if not key_info: + raise HTTPException(status_code=401, detail="Invalid API key") + + return {"api_key": api_key, **key_info} + + +def get_tier_config(key_info: dict[str, Any] = Depends(get_api_key_info)) -> TierConfig: + """Get rate limit config for user's tier.""" + tier = key_info.get("tier", Tier.FREE) + return TIER_CONFIGS[tier] + + +# Create rate limit dependencies for each tier +tier_rate_limits: dict[Tier, RateLimitDependency] = {} +for tier, config in TIER_CONFIGS.items(): + tier_rate_limits[tier] = RateLimitDependency( + limit=config.requests_per_minute, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=config.burst_size, + key_prefix=f"tier_{tier.value}", + ) + + +def api_key_extractor(request: Request) -> str: + """Extract API key for rate limiting.""" + api_key = request.headers.get("X-API-Key", "anonymous") + return f"api:{api_key}" + + +async def apply_tier_rate_limit( + request: Request, + key_info: dict[str, Any] = Depends(get_api_key_info), +) -> dict[str, Any]: + """Apply rate limit based on user's tier.""" + tier = key_info.get("tier", Tier.FREE) + rate_limit_dep = tier_rate_limits[tier] + rate_info = await rate_limit_dep(request) + + return { + "tier": tier, + "config": TIER_CONFIGS[tier], + "rate_info": rate_info, + "key_info": key_info, + } + + +@app.get("/api/v1/data") +async def get_data( + request: Request, + limit_info: dict[str, Any] = Depends(apply_tier_rate_limit), +) -> dict[str, Any]: + """Get data with tier-based rate limiting.""" + return { + "data": {"items": ["item1", "item2", "item3"]}, + "tier": limit_info["tier"].value, + "rate_limit": { + "limit": limit_info["rate_info"].limit, + "remaining": limit_info["rate_info"].remaining, + "reset_at": limit_info["rate_info"].reset_at, + }, + } + + +@app.get("/api/v1/analytics") +async def get_analytics( + request: Request, + limit_info: dict[str, Any] = Depends(apply_tier_rate_limit), +) -> dict[str, Any]: + """Analytics endpoint - requires Pro tier or higher.""" + tier = limit_info["tier"] + config = limit_info["config"] + + if "analytics" not in config.features: + raise HTTPException( + status_code=403, + detail=f"Analytics requires Pro tier or higher. Current tier: {tier.value}", + ) + + return { + "analytics": { + "total_requests": 12345, + "unique_users": 567, + "avg_response_time_ms": 45, + }, + "tier": tier.value, + } + + +@app.get("/api/v1/tier-info") +async def get_tier_info( + key_info: dict[str, Any] = Depends(get_api_key_info), +) -> dict[str, Any]: + """Get information about current tier and limits.""" + tier = key_info.get("tier", Tier.FREE) + config = TIER_CONFIGS[tier] + + return { + "tier": tier.value, + "limits": { + "requests_per_minute": config.requests_per_minute, + "requests_per_hour": config.requests_per_hour, + "requests_per_day": config.requests_per_day, + "burst_size": config.burst_size, + }, + "features": config.features, + "upgrade_options": [t.value for t in Tier if TIER_CONFIGS[t].requests_per_minute > config.requests_per_minute], + } + + +@app.get("/pricing") +async def pricing() -> dict[str, Any]: + """Public pricing information.""" + return { + "tiers": { + tier.value: { + "requests_per_minute": config.requests_per_minute, + "requests_per_day": config.requests_per_day, + "features": config.features, + } + for tier, config in TIER_CONFIGS.items() + } + } + + +if __name__ == "__main__": + import uvicorn + + # Test with different API keys: + # curl -H "X-API-Key: free-key-123" http://localhost:8000/api/v1/data + # curl -H "X-API-Key: pro-key-789" http://localhost:8000/api/v1/analytics + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/09_custom_responses.py b/examples/09_custom_responses.py new file mode 100644 index 0000000..fbe7cd7 --- /dev/null +++ b/examples/09_custom_responses.py @@ -0,0 +1,208 @@ +"""Examples demonstrating custom rate limit responses and callbacks.""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse + +from fastapi_traffic import ( + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.limiter import set_limiter + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Custom Responses Example", lifespan=lifespan) + + +# 1. Standard JSON error response +@app.exception_handler(RateLimitExceeded) +async def json_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + """Standard JSON response for API clients.""" + headers = exc.limit_info.to_headers() if exc.limit_info else {} + + return JSONResponse( + status_code=429, + content={ + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": exc.message, + "retry_after_seconds": exc.retry_after, + "documentation_url": "https://docs.example.com/rate-limits", + }, + "request_id": request.headers.get("X-Request-ID", "unknown"), + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + headers=headers, + ) + + +# 2. Callback for logging/monitoring when requests are blocked +async def log_blocked_request(request: Request, info: Any) -> None: + """Log blocked requests for monitoring.""" + client_ip = request.client.host if request.client else "unknown" + path = request.url.path + user_agent = request.headers.get("User-Agent", "unknown") + + logger.warning( + "Rate limit exceeded: ip=%s path=%s user_agent=%s remaining=%s", + client_ip, + path, + user_agent, + info.remaining if info else "unknown", + ) + + # In production, you might: + # - Send to metrics system (Prometheus, DataDog, etc.) + # - Trigger alerts for suspicious patterns + # - Update a blocklist for repeat offenders + + +@app.get("/api/monitored") +@rate_limit( + limit=5, + window_size=60, + on_blocked=log_blocked_request, +) +async def monitored_endpoint(request: Request) -> dict[str, str]: + """Endpoint with blocked request logging.""" + return {"message": "Success"} + + +# 3. Custom error messages per endpoint +@app.get("/api/search") +@rate_limit( + limit=10, + window_size=60, + error_message="Search rate limit exceeded. Please wait before searching again.", +) +async def search_endpoint(request: Request, q: str = "") -> dict[str, Any]: + """Search with custom error message.""" + return {"query": q, "results": []} + + +@app.get("/api/upload") +@rate_limit( + limit=5, + window_size=300, # 5 uploads per 5 minutes + error_message="Upload limit reached. You can upload 5 files every 5 minutes.", +) +async def upload_endpoint(request: Request) -> dict[str, str]: + """Upload with custom error message.""" + return {"message": "Upload successful"} + + +# 4. Different response formats based on Accept header +@app.get("/api/flexible") +@rate_limit(limit=10, window_size=60) +async def flexible_endpoint(request: Request) -> dict[str, str]: + """Endpoint that returns different formats.""" + return {"message": "Success", "data": "Some data"} + + +# Custom exception handler that respects Accept header +@app.exception_handler(RateLimitExceeded) +async def flexible_rate_limit_handler(request: Request, exc: RateLimitExceeded): + """Return response in format matching Accept header.""" + accept = request.headers.get("Accept", "application/json") + headers = exc.limit_info.to_headers() if exc.limit_info else {} + + if "text/html" in accept: + html_content = f""" + + + Rate Limit Exceeded + +

429 - Too Many Requests

+

{exc.message}

+

Please try again in {exc.retry_after:.0f} seconds.

+ + + """ + return HTMLResponse(content=html_content, status_code=429, headers=headers) + + elif "text/plain" in accept: + return PlainTextResponse( + content=f"Rate limit exceeded. Retry after {exc.retry_after:.0f} seconds.", + status_code=429, + headers=headers, + ) + + else: + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + headers=headers, + ) + + +# 5. Include helpful information in response headers +@app.get("/api/verbose-headers") +@rate_limit( + limit=10, + window_size=60, + include_headers=True, # Includes X-RateLimit-* headers +) +async def verbose_headers_endpoint(request: Request) -> dict[str, Any]: + """Response includes detailed rate limit headers.""" + return { + "message": "Check response headers for rate limit info", + "headers_included": [ + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ], + } + + +# 6. Graceful degradation - return cached/stale data instead of error +cached_data = {"data": "Cached response", "cached_at": datetime.now(timezone.utc).isoformat()} + + +async def return_cached_on_limit(request: Request, info: Any) -> None: + """Log when rate limited (callback doesn't prevent exception).""" + logger.info("Returning cached data due to rate limit") + # This callback is called when blocked, but doesn't prevent the exception + # To actually return cached data, you'd need custom middleware + + +@app.get("/api/graceful") +@rate_limit( + limit=5, + window_size=60, + on_blocked=return_cached_on_limit, +) +async def graceful_endpoint(request: Request) -> dict[str, str]: + """Endpoint with graceful degradation.""" + return {"message": "Fresh data", "timestamp": datetime.now(timezone.utc).isoformat()} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/10_advanced_patterns.py b/examples/10_advanced_patterns.py new file mode 100644 index 0000000..a81b123 --- /dev/null +++ b/examples/10_advanced_patterns.py @@ -0,0 +1,325 @@ +"""Advanced patterns and real-world use cases for rate limiting.""" + +from __future__ import annotations + +import hashlib +import time +from contextlib import asynccontextmanager +from typing import Any + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + Algorithm, + MemoryBackend, + RateLimitExceeded, + RateLimiter, + rate_limit, +) +from fastapi_traffic.core.decorator import RateLimitDependency +from fastapi_traffic.core.limiter import set_limiter + +backend = MemoryBackend() +limiter = RateLimiter(backend) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + +app = FastAPI(title="Advanced Patterns", lifespan=lifespan) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + return JSONResponse( + status_code=429, + content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after}, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) + + +# ============================================================================= +# Pattern 1: Cost-based rate limiting +# Different operations consume different amounts of quota +# ============================================================================= + +@app.get("/api/list") +@rate_limit(limit=100, window_size=60, cost=1) +async def list_items(request: Request) -> dict[str, Any]: + """Cheap operation - costs 1 token.""" + return {"items": ["a", "b", "c"], "cost": 1} + + +@app.get("/api/details/{item_id}") +@rate_limit(limit=100, window_size=60, cost=5) +async def get_details(request: Request, item_id: str) -> dict[str, Any]: + """Medium operation - costs 5 tokens.""" + return {"item_id": item_id, "details": "...", "cost": 5} + + +@app.post("/api/generate") +@rate_limit(limit=100, window_size=60, cost=20) +async def generate_content(request: Request) -> dict[str, Any]: + """Expensive operation - costs 20 tokens.""" + return {"generated": "AI-generated content...", "cost": 20} + + +@app.post("/api/bulk-export") +@rate_limit(limit=100, window_size=60, cost=50) +async def bulk_export(request: Request) -> dict[str, Any]: + """Very expensive operation - costs 50 tokens.""" + return {"export_url": "https://...", "cost": 50} + + +# ============================================================================= +# Pattern 2: Sliding scale exemptions +# Gradually reduce limits instead of hard blocking +# ============================================================================= + +def get_request_priority(request: Request) -> int: + """Determine request priority (higher = more important).""" + # Premium users get higher priority + if request.headers.get("X-Premium-User") == "true": + return 100 + + # Authenticated users get medium priority + if request.headers.get("Authorization"): + return 50 + + # Anonymous users get lowest priority + return 10 + + +def should_exempt_high_priority(request: Request) -> bool: + """Exempt high-priority requests from rate limiting.""" + return get_request_priority(request) >= 100 + + +@app.get("/api/priority-based") +@rate_limit( + limit=10, + window_size=60, + exempt_when=should_exempt_high_priority, +) +async def priority_endpoint(request: Request) -> dict[str, Any]: + """Premium users are exempt from rate limits.""" + priority = get_request_priority(request) + return { + "message": "Success", + "priority": priority, + "exempt": priority >= 100, + } + + +# ============================================================================= +# Pattern 3: Rate limit by resource, not just user +# Prevent abuse of specific resources +# ============================================================================= + +def resource_key_extractor(request: Request) -> str: + """Rate limit by resource ID + user.""" + resource_id = request.path_params.get("resource_id", "unknown") + user_id = request.headers.get("X-User-ID", "anonymous") + return f"resource:{resource_id}:user:{user_id}" + + +@app.get("/api/resources/{resource_id}") +@rate_limit( + limit=10, + window_size=60, + key_extractor=resource_key_extractor, +) +async def get_resource(request: Request, resource_id: str) -> dict[str, str]: + """Each user can access each resource 10 times per minute.""" + return {"resource_id": resource_id, "data": "..."} + + +# ============================================================================= +# Pattern 4: Login/authentication rate limiting +# Prevent brute force attacks +# ============================================================================= + +def login_key_extractor(request: Request) -> str: + """Rate limit by IP + username to prevent brute force.""" + ip = request.client.host if request.client else "unknown" + # In real app, parse username from request body + username = request.headers.get("X-Username", "unknown") + return f"login:{ip}:{username}" + + +@app.post("/auth/login") +@rate_limit( + limit=5, + window_size=300, # 5 attempts per 5 minutes + algorithm=Algorithm.SLIDING_WINDOW, # Precise tracking for security + key_extractor=login_key_extractor, + error_message="Too many login attempts. Please try again in 5 minutes.", +) +async def login(request: Request) -> dict[str, str]: + """Login endpoint with brute force protection.""" + return {"message": "Login successful", "token": "..."} + + +# Password reset - even stricter limits +def password_reset_key(request: Request) -> str: + ip = request.client.host if request.client else "unknown" + return f"password_reset:{ip}" + + +@app.post("/auth/password-reset") +@rate_limit( + limit=3, + window_size=3600, # 3 attempts per hour + key_extractor=password_reset_key, + error_message="Too many password reset requests. Please try again later.", +) +async def password_reset(request: Request) -> dict[str, str]: + """Password reset with strict rate limiting.""" + return {"message": "Password reset email sent"} + + +# ============================================================================= +# Pattern 5: Webhook/callback rate limiting +# Limit outgoing requests to prevent overwhelming external services +# ============================================================================= + +webhook_rate_limit = RateLimitDependency( + limit=100, + window_size=60, + key_prefix="webhook", +) + + +async def check_webhook_limit( + request: Request, + webhook_url: str, +) -> None: + """Check rate limit before sending webhook.""" + # Create key based on destination domain + from urllib.parse import urlparse + domain = urlparse(webhook_url).netloc + _key = f"webhook:{domain}" # Would be used with limiter in production + + # Manually check limit (simplified example) + # In production, you'd use the limiter directly + _ = _key # Suppress unused variable warning + + +@app.post("/api/send-webhook") +async def send_webhook( + request: Request, + webhook_url: str = "https://example.com/webhook", + rate_info: Any = Depends(webhook_rate_limit), +) -> dict[str, Any]: + """Send webhook with rate limiting to protect external services.""" + # await check_webhook_limit(request, webhook_url) + return { + "message": "Webhook sent", + "destination": webhook_url, + "remaining_quota": rate_info.remaining, + } + + +# ============================================================================= +# Pattern 6: Request fingerprinting +# Detect and limit similar requests (e.g., spam prevention) +# ============================================================================= + +def request_fingerprint(request: Request) -> str: + """Create fingerprint based on request characteristics.""" + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("User-Agent", "") + accept_language = request.headers.get("Accept-Language", "") + + # Create hash of request characteristics + fingerprint_data = f"{ip}:{user_agent}:{accept_language}" + fingerprint = hashlib.md5(fingerprint_data.encode()).hexdigest()[:16] + + return f"fingerprint:{fingerprint}" + + +@app.post("/api/submit-form") +@rate_limit( + limit=5, + window_size=60, + key_extractor=request_fingerprint, + error_message="Too many submissions from this device.", +) +async def submit_form(request: Request) -> dict[str, str]: + """Form submission with fingerprint-based rate limiting.""" + return {"message": "Form submitted successfully"} + + +# ============================================================================= +# Pattern 7: Time-of-day based limits +# Different limits during peak vs off-peak hours +# ============================================================================= + +def is_peak_hours() -> bool: + """Check if current time is during peak hours (9 AM - 6 PM UTC).""" + current_hour = time.gmtime().tm_hour + return 9 <= current_hour < 18 + + +def peak_aware_exempt(request: Request) -> bool: + """Exempt requests during off-peak hours.""" + return not is_peak_hours() + + +@app.get("/api/peak-aware") +@rate_limit( + limit=10, # Strict limit during peak hours + window_size=60, + exempt_when=peak_aware_exempt, # No limit during off-peak +) +async def peak_aware_endpoint(request: Request) -> dict[str, Any]: + """Stricter limits during peak hours.""" + return { + "message": "Success", + "is_peak_hours": is_peak_hours(), + "rate_limited": is_peak_hours(), + } + + +# ============================================================================= +# Pattern 8: Cascading limits (multiple tiers) +# ============================================================================= + +per_second = RateLimitDependency(limit=5, window_size=1, key_prefix="sec") +per_minute = RateLimitDependency(limit=100, window_size=60, key_prefix="min") +per_hour = RateLimitDependency(limit=1000, window_size=3600, key_prefix="hour") + + +async def cascading_limits( + request: Request, + sec_info: Any = Depends(per_second), + min_info: Any = Depends(per_minute), + hour_info: Any = Depends(per_hour), +) -> dict[str, Any]: + """Apply multiple rate limit tiers.""" + return { + "per_second": {"remaining": sec_info.remaining}, + "per_minute": {"remaining": min_info.remaining}, + "per_hour": {"remaining": hour_info.remaining}, + } + + +@app.get("/api/cascading") +async def cascading_endpoint( + request: Request, + limits: dict[str, Any] = Depends(cascading_limits), +) -> dict[str, Any]: + """Endpoint with per-second, per-minute, and per-hour limits.""" + return {"message": "Success", "limits": limits} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b896877 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,133 @@ +# FastAPI Traffic Examples + +This directory contains comprehensive examples demonstrating how to use the `fastapi-traffic` rate limiting library. + +## Basic Examples + +### 01_quickstart.py +Minimal setup to get rate limiting working. Start here if you're new to the library. +- Basic backend and limiter setup +- Exception handler for rate limit errors +- Simple decorator usage + +### 02_algorithms.py +Demonstrates all available rate limiting algorithms: +- **Fixed Window** - Simple, resets at fixed intervals +- **Sliding Window** - Most precise, stores timestamps +- **Sliding Window Counter** - Balance of precision and efficiency (default) +- **Token Bucket** - Allows controlled bursts +- **Leaky Bucket** - Smooths out traffic + +### 03_backends.py +Shows different storage backends: +- **MemoryBackend** - Fast, ephemeral (default) +- **SQLiteBackend** - Persistent, single-instance +- **RedisBackend** - Distributed, multi-instance + +### 04_key_extractors.py +Custom key extractors for different rate limiting strategies: +- Rate limit by IP address (default) +- Rate limit by API key +- Rate limit by user ID +- Rate limit by endpoint + IP +- Rate limit by tenant/organization +- Composite keys (user + action) + +### 05_middleware.py +Middleware-based rate limiting for global protection: +- Basic middleware setup +- Custom configuration options +- Path and IP exemptions +- Alternative middleware classes + +## Advanced Examples + +### 06_dependency_injection.py +Using FastAPI's dependency injection system: +- Basic rate limit dependency +- Tier-based rate limiting +- Combining multiple rate limits +- Conditional exemptions + +### 07_redis_distributed.py +Redis backend for distributed deployments: +- Multi-instance rate limiting +- Shared counters across nodes +- Health checks and statistics +- Fallback to memory backend + +### 08_tiered_api.py +Production-ready tiered API example: +- Free, Starter, Pro, Enterprise tiers +- Different limits per tier +- Feature gating based on tier +- API key validation + +### 09_custom_responses.py +Customizing rate limit responses: +- Custom JSON error responses +- Logging/monitoring callbacks +- Different response formats (JSON, HTML, plain text) +- Rate limit headers + +### 10_advanced_patterns.py +Real-world patterns and use cases: +- **Cost-based limiting** - Different operations cost different amounts +- **Priority exemptions** - Premium users exempt from limits +- **Resource-based limiting** - Limit by resource ID + user +- **Login protection** - Brute force prevention +- **Webhook limiting** - Protect external services +- **Request fingerprinting** - Spam prevention +- **Time-of-day limits** - Peak vs off-peak hours +- **Cascading limits** - Per-second, per-minute, per-hour + +## Running Examples + +Each example is a standalone FastAPI application. Run with: + +```bash +# Using uvicorn directly +uvicorn examples.01_quickstart:app --reload + +# Or run the file directly +python examples/01_quickstart.py +``` + +## Testing Rate Limits + +Use curl or httpie to test: + +```bash +# Basic request +curl http://localhost:8000/api/basic + +# With API key +curl -H "X-API-Key: my-key" http://localhost:8000/api/by-api-key + +# Check rate limit headers +curl -i http://localhost:8000/api/data + +# Rapid requests to trigger rate limit +for i in {1..20}; do curl http://localhost:8000/api/basic; done +``` + +## Environment Variables + +Some examples support configuration via environment variables: + +- `RATE_LIMIT_BACKEND` - Backend type (memory, sqlite, redis) +- `REDIS_URL` - Redis connection URL for distributed examples + +## Requirements + +Basic examples only need `fastapi-traffic` and `uvicorn`: + +```bash +pip install fastapi-traffic uvicorn +``` + +For Redis examples: + +```bash +pip install redis +``` diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..f304d17 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,171 @@ +"""Basic usage examples for fastapi-traffic.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse + +from fastapi_traffic import ( + Algorithm, + RateLimitExceeded, + RateLimiter, + SQLiteBackend, + rate_limit, +) +from fastapi_traffic.core.decorator import RateLimitDependency +from fastapi_traffic.core.limiter import set_limiter + +# Configure global rate limiter with SQLite backend for persistence +backend = SQLiteBackend("rate_limits.db") +limiter = RateLimiter(backend) +set_limiter(limiter) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Manage application lifespan - startup and shutdown.""" + # Startup: Initialize the rate limiter + await limiter.initialize() + yield + # Shutdown: Cleanup + await limiter.close() + + +app = FastAPI(title="FastAPI Traffic Example", lifespan=lifespan) + + +# Exception handler for rate limit exceeded +@app.exception_handler(RateLimitExceeded) +async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: + """Handle rate limit exceeded exceptions.""" + headers = exc.limit_info.to_headers() if exc.limit_info else {} + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + headers=headers, + ) + + +# Example 1: Basic decorator usage +@app.get("/api/basic") +@rate_limit(100, 60) # 100 requests per minute +async def basic_endpoint(request: Request) -> dict[str, str]: + """Basic rate-limited endpoint.""" + return {"message": "Hello, World!"} + + +# Example 2: Custom algorithm +@app.get("/api/token-bucket") +@rate_limit( + limit=50, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=10, # Allow bursts of up to 10 requests +) +async def token_bucket_endpoint(request: Request) -> dict[str, str]: + """Endpoint using token bucket algorithm.""" + return {"message": "Token bucket rate limiting"} + + +# Example 3: Sliding window for precise rate limiting +@app.get("/api/sliding-window") +@rate_limit( + limit=30, + window_size=60, + algorithm=Algorithm.SLIDING_WINDOW, +) +async def sliding_window_endpoint(request: Request) -> dict[str, str]: + """Endpoint using sliding window algorithm.""" + return {"message": "Sliding window rate limiting"} + + +# Example 4: Custom key extractor (rate limit by API key) +def api_key_extractor(request: Request) -> str: + """Extract API key from header for rate limiting.""" + api_key = request.headers.get("X-API-Key", "anonymous") + return f"api_key:{api_key}" + + +@app.get("/api/by-api-key") +@rate_limit( + limit=1000, + window_size=3600, # 1000 requests per hour + key_extractor=api_key_extractor, +) +async def api_key_endpoint(request: Request) -> dict[str, str]: + """Endpoint rate limited by API key.""" + return {"message": "Rate limited by API key"} + + +# Example 5: Using dependency injection +rate_limit_dep = RateLimitDependency(limit=20, window_size=60) + + +@app.get("/api/dependency") +async def dependency_endpoint( + request: Request, + rate_info: dict[str, object] = Depends(rate_limit_dep), +) -> dict[str, object]: + """Endpoint using rate limit as dependency.""" + return { + "message": "Rate limit info available", + "rate_limit": rate_info, + } + + +# Example 6: Exempt certain requests +def is_admin(request: Request) -> bool: + """Check if request is from admin.""" + return request.headers.get("X-Admin-Token") == "secret-admin-token" + + +@app.get("/api/admin-exempt") +@rate_limit( + limit=10, + window_size=60, + exempt_when=is_admin, +) +async def admin_exempt_endpoint(request: Request) -> dict[str, str]: + """Endpoint with admin exemption.""" + return {"message": "Admins are exempt from rate limiting"} + + +# Example 7: Different costs for different operations +@app.post("/api/expensive") +@rate_limit( + limit=100, + window_size=60, + cost=10, # This endpoint costs 10 tokens per request +) +async def expensive_endpoint(request: Request) -> dict[str, str]: + """Expensive operation that costs more tokens.""" + return {"message": "Expensive operation completed"} + + +# Example 8: Global middleware rate limiting +# Uncomment to enable global rate limiting +# app.add_middleware( +# RateLimitMiddleware, +# limit=1000, +# window_size=60, +# exempt_paths={"/health", "/docs", "/openapi.json"}, +# ) + + +@app.get("/health") +async def health_check() -> dict[str, str]: + """Health check endpoint (typically exempt from rate limiting).""" + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/fastapi_traffic/__init__.py b/fastapi_traffic/__init__.py new file mode 100644 index 0000000..7bda8ae --- /dev/null +++ b/fastapi_traffic/__init__.py @@ -0,0 +1,36 @@ +"""FastAPI Traffic - Production-grade rate limiting for FastAPI.""" + +from fastapi_traffic.core.decorator import rate_limit +from fastapi_traffic.core.limiter import RateLimiter +from fastapi_traffic.core.config import RateLimitConfig +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.backends.base import Backend +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.backends.sqlite import SQLiteBackend +from fastapi_traffic.exceptions import ( + RateLimitExceeded, + BackendError, + ConfigurationError, +) + +__version__ = "0.1.0" +__all__ = [ + "rate_limit", + "RateLimiter", + "RateLimitConfig", + "Algorithm", + "Backend", + "MemoryBackend", + "SQLiteBackend", + "RateLimitExceeded", + "BackendError", + "ConfigurationError", +] + +# Optional Redis backend +try: + from fastapi_traffic.backends.redis import RedisBackend + + __all__.append("RedisBackend") +except ImportError: + pass diff --git a/fastapi_traffic/backends/__init__.py b/fastapi_traffic/backends/__init__.py new file mode 100644 index 0000000..51b556a --- /dev/null +++ b/fastapi_traffic/backends/__init__.py @@ -0,0 +1,19 @@ +"""Backend implementations for rate limit storage.""" + +from fastapi_traffic.backends.base import Backend +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.backends.sqlite import SQLiteBackend + +__all__ = [ + "Backend", + "MemoryBackend", + "SQLiteBackend", +] + +# Optional Redis backend +try: + from fastapi_traffic.backends.redis import RedisBackend + + __all__.append("RedisBackend") +except ImportError: + pass diff --git a/fastapi_traffic/backends/base.py b/fastapi_traffic/backends/base.py new file mode 100644 index 0000000..8a43d87 --- /dev/null +++ b/fastapi_traffic/backends/base.py @@ -0,0 +1,89 @@ +"""Abstract base class for rate limit storage backends.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class Backend(ABC): + """Abstract base class for rate limit storage backends.""" + + @abstractmethod + async def get(self, key: str) -> dict[str, Any] | None: + """Get the current state for a key. + + Args: + key: The rate limit key. + + Returns: + The stored state dictionary or None if not found. + """ + ... + + @abstractmethod + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + """Set the state for a key with TTL. + + Args: + key: The rate limit key. + value: The state dictionary to store. + ttl: Time-to-live in seconds. + """ + ... + + @abstractmethod + async def delete(self, key: str) -> None: + """Delete the state for a key. + + Args: + key: The rate limit key. + """ + ... + + @abstractmethod + async def exists(self, key: str) -> bool: + """Check if a key exists. + + Args: + key: The rate limit key. + + Returns: + True if the key exists, False otherwise. + """ + ... + + @abstractmethod + async def increment(self, key: str, amount: int = 1) -> int: + """Atomically increment a counter. + + Args: + key: The rate limit key. + amount: The amount to increment by. + + Returns: + The new value after incrementing. + """ + ... + + @abstractmethod + async def clear(self) -> None: + """Clear all rate limit data.""" + ... + + async def close(self) -> None: + """Close the backend connection.""" + pass + + async def __aenter__(self) -> Backend: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + await self.close() diff --git a/fastapi_traffic/backends/memory.py b/fastapi_traffic/backends/memory.py new file mode 100644 index 0000000..f69dea2 --- /dev/null +++ b/fastapi_traffic/backends/memory.py @@ -0,0 +1,139 @@ +"""In-memory backend for rate limiting - suitable for single-process applications.""" + +from __future__ import annotations + +import asyncio +import time +from collections import OrderedDict +from typing import Any + +from fastapi_traffic.backends.base import Backend + + +class MemoryBackend(Backend): + """Thread-safe in-memory backend with LRU eviction and TTL support.""" + + __slots__ = ("_data", "_lock", "_max_size", "_cleanup_interval", "_cleanup_task") + + def __init__( + self, + *, + max_size: int = 10000, + cleanup_interval: float = 60.0, + ) -> None: + """Initialize the memory backend. + + Args: + max_size: Maximum number of entries to store (LRU eviction). + cleanup_interval: Interval in seconds for cleaning expired entries. + """ + self._data: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() + self._lock = asyncio.Lock() + self._max_size = max_size + self._cleanup_interval = cleanup_interval + self._cleanup_task: asyncio.Task[None] | None = None + + async def start_cleanup(self) -> None: + """Start the background cleanup task.""" + if self._cleanup_task is None: + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def _cleanup_loop(self) -> None: + """Background task to clean up expired entries.""" + while True: + try: + await asyncio.sleep(self._cleanup_interval) + await self._cleanup_expired() + except asyncio.CancelledError: + break + except Exception: + pass + + async def _cleanup_expired(self) -> None: + """Remove expired entries.""" + now = time.time() + async with self._lock: + expired_keys = [ + key for key, (_, expires_at) in self._data.items() if expires_at <= now + ] + for key in expired_keys: + del self._data[key] + + def _evict_if_needed(self) -> None: + """Evict oldest entries if over max size (must be called with lock held).""" + while len(self._data) > self._max_size: + self._data.popitem(last=False) + + async def get(self, key: str) -> dict[str, Any] | None: + """Get the current state for a key.""" + async with self._lock: + if key not in self._data: + return None + + value, expires_at = self._data[key] + if expires_at <= time.time(): + del self._data[key] + return None + + self._data.move_to_end(key) + return value.copy() + + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + """Set the state for a key with TTL.""" + expires_at = time.time() + ttl + async with self._lock: + self._data[key] = (value.copy(), expires_at) + self._data.move_to_end(key) + self._evict_if_needed() + + async def delete(self, key: str) -> None: + """Delete the state for a key.""" + async with self._lock: + self._data.pop(key, None) + + async def exists(self, key: str) -> bool: + """Check if a key exists and is not expired.""" + async with self._lock: + if key not in self._data: + return False + + _, expires_at = self._data[key] + if expires_at <= time.time(): + del self._data[key] + return False + + return True + + async def increment(self, key: str, amount: int = 1) -> int: + """Atomically increment a counter.""" + async with self._lock: + if key in self._data: + value, expires_at = self._data[key] + if expires_at > time.time(): + current = int(value.get("count", 0)) + new_value = current + amount + value["count"] = new_value + self._data[key] = (value, expires_at) + return new_value + + return amount + + async def clear(self) -> None: + """Clear all rate limit data.""" + async with self._lock: + self._data.clear() + + async def close(self) -> None: + """Stop cleanup task and clear data.""" + if self._cleanup_task is not None: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + await self.clear() + + def __len__(self) -> int: + """Return the number of stored entries.""" + return len(self._data) diff --git a/fastapi_traffic/backends/redis.py b/fastapi_traffic/backends/redis.py new file mode 100644 index 0000000..2d80c3d --- /dev/null +++ b/fastapi_traffic/backends/redis.py @@ -0,0 +1,232 @@ +"""Redis backend for rate limiting - distributed storage for multi-node deployments.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from fastapi_traffic.backends.base import Backend +from fastapi_traffic.exceptions import BackendError + +if TYPE_CHECKING: + from redis.asyncio import Redis + + +class RedisBackend(Backend): + """Redis-based backend for distributed rate limiting.""" + + __slots__ = ("_client", "_key_prefix", "_owns_client") + + def __init__( + self, + client: Redis[bytes], + *, + key_prefix: str = "fastapi_traffic", + ) -> None: + """Initialize the Redis backend. + + Args: + client: An async Redis client instance. + key_prefix: Prefix for all rate limit keys. + """ + self._client = client + self._key_prefix = key_prefix + self._owns_client = False + + @classmethod + async def from_url( + cls, + url: str = "redis://localhost:6379/0", + *, + key_prefix: str = "fastapi_traffic", + **kwargs: Any, + ) -> RedisBackend: + """Create a RedisBackend from a Redis URL. + + Args: + url: Redis connection URL. + key_prefix: Prefix for all rate limit keys. + **kwargs: Additional arguments passed to Redis.from_url(). + + Returns: + A new RedisBackend instance. + """ + try: + from redis.asyncio import Redis + except ImportError as e: + msg = "redis package is required for RedisBackend. Install with: pip install redis" + raise ImportError(msg) from e + + client: Redis[bytes] = Redis.from_url(url, **kwargs) + instance = cls(client, key_prefix=key_prefix) + instance._owns_client = True + return instance + + def _make_key(self, key: str) -> str: + """Create a prefixed key.""" + return f"{self._key_prefix}:{key}" + + async def get(self, key: str) -> dict[str, Any] | None: + """Get the current state for a key.""" + try: + full_key = self._make_key(key) + data = await self._client.get(full_key) + if data is None: + return None + result: dict[str, Any] = json.loads(data) + return result + except Exception as e: + raise BackendError(f"Failed to get key {key}", original_error=e) + + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + """Set the state for a key with TTL.""" + try: + full_key = self._make_key(key) + data = json.dumps(value) + await self._client.setex(full_key, int(ttl) + 1, data) + except Exception as e: + raise BackendError(f"Failed to set key {key}", original_error=e) + + async def delete(self, key: str) -> None: + """Delete the state for a key.""" + try: + full_key = self._make_key(key) + await self._client.delete(full_key) + except Exception as e: + raise BackendError(f"Failed to delete key {key}", original_error=e) + + async def exists(self, key: str) -> bool: + """Check if a key exists.""" + try: + full_key = self._make_key(key) + result = await self._client.exists(full_key) + return bool(result) + except Exception as e: + raise BackendError(f"Failed to check key {key}", original_error=e) + + async def increment(self, key: str, amount: int = 1) -> int: + """Atomically increment a counter using Redis INCRBY.""" + try: + full_key = self._make_key(key) + result = await self._client.incrby(full_key, amount) + return int(result) + except Exception as e: + raise BackendError(f"Failed to increment key {key}", original_error=e) + + async def clear(self) -> None: + """Clear all rate limit data with this prefix.""" + try: + pattern = f"{self._key_prefix}:*" + cursor: int = 0 + while True: + cursor, keys = await self._client.scan(cursor, match=pattern, count=100) + if keys: + await self._client.delete(*keys) + if cursor == 0: + break + except Exception as e: + raise BackendError("Failed to clear rate limits", original_error=e) + + async def close(self) -> None: + """Close the Redis connection if we own it.""" + if self._owns_client: + await self._client.aclose() + + async def ping(self) -> bool: + """Check if Redis is reachable.""" + try: + await self._client.ping() + return True + except Exception: + return False + + async def get_stats(self) -> dict[str, Any]: + """Get statistics about the rate limit storage.""" + try: + pattern = f"{self._key_prefix}:*" + cursor: int = 0 + count = 0 + while True: + cursor, keys = await self._client.scan(cursor, match=pattern, count=100) + count += len(keys) + if cursor == 0: + break + + info = await self._client.info("memory") + return { + "total_keys": count, + "used_memory": info.get("used_memory_human", "unknown"), + "key_prefix": self._key_prefix, + } + except Exception as e: + raise BackendError("Failed to get stats", original_error=e) + + +# Lua scripts for atomic operations +SLIDING_WINDOW_SCRIPT = """ +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window_size = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local window_start = now - window_size + +-- Remove expired entries +redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start) + +-- Count current entries +local count = redis.call('ZCARD', key) + +if count < limit then + -- Add new entry + redis.call('ZADD', key, now, now .. ':' .. math.random()) + redis.call('EXPIRE', key, math.ceil(window_size) + 1) + return {1, limit - count - 1} +else + -- Get oldest entry for retry-after calculation + local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + local retry_after = 0 + if #oldest > 0 then + retry_after = oldest[2] + window_size - now + end + return {0, 0, retry_after} +end +""" + +TOKEN_BUCKET_SCRIPT = """ +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local bucket_size = tonumber(ARGV[2]) +local refill_rate = tonumber(ARGV[3]) +local ttl = tonumber(ARGV[4]) + +local data = redis.call('GET', key) +local tokens, last_update + +if data then + local decoded = cjson.decode(data) + tokens = decoded.tokens + last_update = decoded.last_update +else + tokens = bucket_size + last_update = now +end + +-- Refill tokens +local elapsed = now - last_update +tokens = math.min(bucket_size, tokens + elapsed * refill_rate) + +local allowed = 0 +local retry_after = 0 + +if tokens >= 1 then + tokens = tokens - 1 + allowed = 1 +else + retry_after = (1 - tokens) / refill_rate +end + +-- Save state +redis.call('SETEX', key, ttl, cjson.encode({tokens = tokens, last_update = now})) + +return {allowed, math.floor(tokens), retry_after} +""" diff --git a/fastapi_traffic/backends/sqlite.py b/fastapi_traffic/backends/sqlite.py new file mode 100644 index 0000000..ea13395 --- /dev/null +++ b/fastapi_traffic/backends/sqlite.py @@ -0,0 +1,298 @@ +"""SQLite backend for rate limiting - persistent storage for single-node deployments.""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import time +from pathlib import Path +from typing import Any + +from fastapi_traffic.backends.base import Backend +from fastapi_traffic.exceptions import BackendError + + +class SQLiteBackend(Backend): + """SQLite-based backend with connection pooling and async support.""" + + __slots__ = ( + "_db_path", + "_connection", + "_lock", + "_cleanup_interval", + "_cleanup_task", + "_pool_size", + "_connections", + ) + + def __init__( + self, + db_path: str | Path = ":memory:", + *, + cleanup_interval: float = 300.0, + pool_size: int = 5, + ) -> None: + """Initialize the SQLite backend. + + Args: + db_path: Path to SQLite database file or ":memory:" for in-memory. + cleanup_interval: Interval in seconds for cleaning expired entries. + pool_size: Number of connections in the pool. + """ + self._db_path = str(db_path) + self._connection: sqlite3.Connection | None = None + self._lock = asyncio.Lock() + self._cleanup_interval = cleanup_interval + self._cleanup_task: asyncio.Task[None] | None = None + self._pool_size = pool_size + self._connections: list[sqlite3.Connection] = [] + + async def initialize(self) -> None: + """Initialize the database and create tables.""" + await self._ensure_connection() + await self._create_tables() + if self._cleanup_task is None: + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def _ensure_connection(self) -> sqlite3.Connection: + """Ensure a database connection exists.""" + if self._connection is None: + loop = asyncio.get_event_loop() + self._connection = await loop.run_in_executor( + None, self._create_connection + ) + assert self._connection is not None + return self._connection + + def _create_connection(self) -> sqlite3.Connection: + """Create a new SQLite connection with optimized settings.""" + conn = sqlite3.connect( + self._db_path, + check_same_thread=False, + isolation_level=None, + ) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA cache_size=10000") + conn.execute("PRAGMA temp_store=MEMORY") + conn.row_factory = sqlite3.Row + return conn + + async def _create_tables(self) -> None: + """Create the rate limit tables.""" + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._create_tables_sync, conn) + + def _create_tables_sync(self, conn: sqlite3.Connection) -> None: + """Synchronously create tables.""" + conn.execute(""" + CREATE TABLE IF NOT EXISTS rate_limits ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + expires_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_expires_at ON rate_limits(expires_at) + """) + + async def _cleanup_loop(self) -> None: + """Background task to clean up expired entries.""" + while True: + try: + await asyncio.sleep(self._cleanup_interval) + await self._cleanup_expired() + except asyncio.CancelledError: + break + except Exception: + pass + + async def _cleanup_expired(self) -> None: + """Remove expired entries.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: conn.execute( + "DELETE FROM rate_limits WHERE expires_at <= ?", (time.time(),) + ), + ) + except Exception as e: + raise BackendError("Failed to cleanup expired entries", original_error=e) + + async def get(self, key: str) -> dict[str, Any] | None: + """Get the current state for a key.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + + def _get() -> dict[str, Any] | None: + cursor = conn.execute( + "SELECT data, expires_at FROM rate_limits WHERE key = ?", + (key,), + ) + row = cursor.fetchone() + if row is None: + return None + + expires_at = row["expires_at"] + if expires_at <= time.time(): + conn.execute("DELETE FROM rate_limits WHERE key = ?", (key,)) + return None + + data: dict[str, Any] = json.loads(row["data"]) + return data + + return await loop.run_in_executor(None, _get) + except Exception as e: + raise BackendError(f"Failed to get key {key}", original_error=e) + + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + """Set the state for a key with TTL.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + expires_at = time.time() + ttl + data_json = json.dumps(value) + + def _set() -> None: + conn.execute( + """ + INSERT OR REPLACE INTO rate_limits (key, data, expires_at) + VALUES (?, ?, ?) + """, + (key, data_json, expires_at), + ) + + await loop.run_in_executor(None, _set) + except Exception as e: + raise BackendError(f"Failed to set key {key}", original_error=e) + + async def delete(self, key: str) -> None: + """Delete the state for a key.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: conn.execute("DELETE FROM rate_limits WHERE key = ?", (key,)), + ) + except Exception as e: + raise BackendError(f"Failed to delete key {key}", original_error=e) + + async def exists(self, key: str) -> bool: + """Check if a key exists and is not expired.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + + def _exists() -> bool: + cursor = conn.execute( + "SELECT 1 FROM rate_limits WHERE key = ? AND expires_at > ?", + (key, time.time()), + ) + return cursor.fetchone() is not None + + return await loop.run_in_executor(None, _exists) + except Exception as e: + raise BackendError(f"Failed to check key {key}", original_error=e) + + async def increment(self, key: str, amount: int = 1) -> int: + """Atomically increment a counter.""" + async with self._lock: + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + + def _increment() -> int: + cursor = conn.execute( + "SELECT data, expires_at FROM rate_limits WHERE key = ?", + (key,), + ) + row = cursor.fetchone() + + if row is None or row["expires_at"] <= time.time(): + return amount + + data: dict[str, Any] = json.loads(row["data"]) + current = int(data.get("count", 0)) + new_value = current + amount + data["count"] = new_value + + conn.execute( + "UPDATE rate_limits SET data = ? WHERE key = ?", + (json.dumps(data), key), + ) + return new_value + + return await loop.run_in_executor(None, _increment) + except Exception as e: + raise BackendError(f"Failed to increment key {key}", original_error=e) + + async def clear(self) -> None: + """Clear all rate limit data.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, lambda: conn.execute("DELETE FROM rate_limits") + ) + except Exception as e: + raise BackendError("Failed to clear rate limits", original_error=e) + + async def close(self) -> None: + """Close the database connection.""" + if self._cleanup_task is not None: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + + if self._connection is not None: + self._connection.close() + self._connection = None + + for conn in self._connections: + conn.close() + self._connections.clear() + + async def vacuum(self) -> None: + """Optimize the database by running VACUUM.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: conn.execute("VACUUM")) + except Exception as e: + raise BackendError("Failed to vacuum database", original_error=e) + + async def get_stats(self) -> dict[str, Any]: + """Get statistics about the rate limit storage.""" + try: + conn = await self._ensure_connection() + loop = asyncio.get_event_loop() + + def _stats() -> dict[str, Any]: + cursor = conn.execute("SELECT COUNT(*) as total FROM rate_limits") + total = cursor.fetchone()["total"] + + cursor = conn.execute( + "SELECT COUNT(*) as active FROM rate_limits WHERE expires_at > ?", + (time.time(),), + ) + active = cursor.fetchone()["active"] + + return { + "total_entries": total, + "active_entries": active, + "expired_entries": total - active, + "db_path": self._db_path, + } + + return await loop.run_in_executor(None, _stats) + except Exception as e: + raise BackendError("Failed to get stats", original_error=e) diff --git a/fastapi_traffic/core/__init__.py b/fastapi_traffic/core/__init__.py new file mode 100644 index 0000000..0d912bd --- /dev/null +++ b/fastapi_traffic/core/__init__.py @@ -0,0 +1,16 @@ +"""Core rate limiting components.""" + +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import RateLimitConfig +from fastapi_traffic.core.decorator import rate_limit +from fastapi_traffic.core.limiter import RateLimiter +from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult + +__all__ = [ + "Algorithm", + "RateLimitConfig", + "rate_limit", + "RateLimiter", + "RateLimitInfo", + "RateLimitResult", +] diff --git a/fastapi_traffic/core/algorithms.py b/fastapi_traffic/core/algorithms.py new file mode 100644 index 0000000..4a3e1de --- /dev/null +++ b/fastapi_traffic/core/algorithms.py @@ -0,0 +1,466 @@ +"""Rate limiting algorithms implementation.""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING + +from fastapi_traffic.core.models import RateLimitInfo + +if TYPE_CHECKING: + from fastapi_traffic.backends.base import Backend + + +class Algorithm(str, Enum): + """Available rate limiting algorithms.""" + + TOKEN_BUCKET = "token_bucket" + SLIDING_WINDOW = "sliding_window" + FIXED_WINDOW = "fixed_window" + LEAKY_BUCKET = "leaky_bucket" + SLIDING_WINDOW_COUNTER = "sliding_window_counter" + + +class BaseAlgorithm(ABC): + """Base class for rate limiting algorithms.""" + + __slots__ = ("limit", "window_size", "backend", "burst_size") + + def __init__( + self, + limit: int, + window_size: float, + backend: Backend, + *, + burst_size: int | None = None, + ) -> None: + self.limit = limit + self.window_size = window_size + self.backend = backend + self.burst_size = burst_size or limit + + @abstractmethod + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + """Check if request is allowed and update state.""" + ... + + @abstractmethod + async def reset(self, key: str) -> None: + """Reset the rate limit state for a key.""" + ... + + @abstractmethod + async def get_state(self, key: str) -> RateLimitInfo | None: + """Get current state without consuming a token.""" + ... + + +class TokenBucketAlgorithm(BaseAlgorithm): + """Token bucket algorithm - allows bursts up to bucket capacity.""" + + __slots__ = ("refill_rate",) + + def __init__( + self, + limit: int, + window_size: float, + backend: Backend, + *, + burst_size: int | None = None, + ) -> None: + super().__init__(limit, window_size, backend, burst_size=burst_size) + self.refill_rate = limit / window_size + + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + now = time.time() + state = await self.backend.get(key) + + if state is None: + tokens = float(self.burst_size - 1) + await self.backend.set( + key, + {"tokens": tokens, "last_update": now}, + ttl=self.window_size * 2, + ) + return True, RateLimitInfo( + limit=self.limit, + remaining=int(tokens), + reset_at=now + self.window_size, + window_size=self.window_size, + ) + + tokens = float(state.get("tokens", self.burst_size)) + last_update = float(state.get("last_update", now)) + + elapsed = now - last_update + tokens = min(self.burst_size, tokens + elapsed * self.refill_rate) + + if tokens >= 1: + tokens -= 1 + allowed = True + retry_after = None + else: + allowed = False + retry_after = (1 - tokens) / self.refill_rate + + await self.backend.set( + key, + {"tokens": tokens, "last_update": now}, + ttl=self.window_size * 2, + ) + + return allowed, RateLimitInfo( + limit=self.limit, + remaining=int(tokens), + reset_at=now + (self.burst_size - tokens) / self.refill_rate, + retry_after=retry_after, + window_size=self.window_size, + ) + + async def reset(self, key: str) -> None: + await self.backend.delete(key) + + async def get_state(self, key: str) -> RateLimitInfo | None: + now = time.time() + state = await self.backend.get(key) + + if state is None: + return None + + tokens = float(state.get("tokens", self.burst_size)) + last_update = float(state.get("last_update", now)) + + elapsed = now - last_update + tokens = min(self.burst_size, tokens + elapsed * self.refill_rate) + + return RateLimitInfo( + limit=self.limit, + remaining=int(tokens), + reset_at=now + (self.burst_size - tokens) / self.refill_rate, + window_size=self.window_size, + ) + + +class SlidingWindowAlgorithm(BaseAlgorithm): + """Sliding window log algorithm - precise but memory intensive.""" + + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + now = time.time() + window_start = now - self.window_size + state = await self.backend.get(key) + + timestamps: list[float] = [] + if state is not None: + raw_timestamps = state.get("timestamps", []) + timestamps = [ + float(ts) for ts in raw_timestamps if float(ts) > window_start + ] + + if len(timestamps) < self.limit: + timestamps.append(now) + allowed = True + retry_after = None + else: + allowed = False + oldest = min(timestamps) if timestamps else now + retry_after = oldest + self.window_size - now + + await self.backend.set( + key, + {"timestamps": timestamps}, + ttl=self.window_size * 2, + ) + + remaining = max(0, self.limit - len(timestamps)) + reset_at = (min(timestamps) if timestamps else now) + self.window_size + + return allowed, RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + retry_after=retry_after, + window_size=self.window_size, + ) + + async def reset(self, key: str) -> None: + await self.backend.delete(key) + + async def get_state(self, key: str) -> RateLimitInfo | None: + now = time.time() + window_start = now - self.window_size + state = await self.backend.get(key) + + if state is None: + return None + + raw_timestamps = state.get("timestamps", []) + timestamps = [float(ts) for ts in raw_timestamps if float(ts) > window_start] + remaining = max(0, self.limit - len(timestamps)) + reset_at = (min(timestamps) if timestamps else now) + self.window_size + + return RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + window_size=self.window_size, + ) + + +class FixedWindowAlgorithm(BaseAlgorithm): + """Fixed window algorithm - simple and efficient.""" + + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + now = time.time() + window_start = (now // self.window_size) * self.window_size + window_end = window_start + self.window_size + state = await self.backend.get(key) + + count = 0 + if state is not None: + stored_window = float(state.get("window_start", 0)) + if stored_window == window_start: + count = int(state.get("count", 0)) + + if count < self.limit: + count += 1 + allowed = True + retry_after = None + else: + allowed = False + retry_after = window_end - now + + await self.backend.set( + key, + {"count": count, "window_start": window_start}, + ttl=self.window_size * 2, + ) + + return allowed, RateLimitInfo( + limit=self.limit, + remaining=max(0, self.limit - count), + reset_at=window_end, + retry_after=retry_after, + window_size=self.window_size, + ) + + async def reset(self, key: str) -> None: + await self.backend.delete(key) + + async def get_state(self, key: str) -> RateLimitInfo | None: + now = time.time() + window_start = (now // self.window_size) * self.window_size + window_end = window_start + self.window_size + state = await self.backend.get(key) + + if state is None: + return None + + count = 0 + stored_window = float(state.get("window_start", 0)) + if stored_window == window_start: + count = int(state.get("count", 0)) + + return RateLimitInfo( + limit=self.limit, + remaining=max(0, self.limit - count), + reset_at=window_end, + window_size=self.window_size, + ) + + +class LeakyBucketAlgorithm(BaseAlgorithm): + """Leaky bucket algorithm - smooths out bursts.""" + + __slots__ = ("leak_rate",) + + def __init__( + self, + limit: int, + window_size: float, + backend: Backend, + *, + burst_size: int | None = None, + ) -> None: + super().__init__(limit, window_size, backend, burst_size=burst_size) + self.leak_rate = limit / window_size + + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + now = time.time() + state = await self.backend.get(key) + + water_level = 0.0 + if state is not None: + water_level = float(state.get("water_level", 0)) + last_update = float(state.get("last_update", now)) + elapsed = now - last_update + water_level = max(0, water_level - elapsed * self.leak_rate) + + if water_level < self.burst_size: + water_level += 1 + allowed = True + retry_after = None + else: + allowed = False + retry_after = (water_level - self.burst_size + 1) / self.leak_rate + + await self.backend.set( + key, + {"water_level": water_level, "last_update": now}, + ttl=self.window_size * 2, + ) + + remaining = max(0, int(self.burst_size - water_level)) + reset_at = now + water_level / self.leak_rate + + return allowed, RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + retry_after=retry_after, + window_size=self.window_size, + ) + + async def reset(self, key: str) -> None: + await self.backend.delete(key) + + async def get_state(self, key: str) -> RateLimitInfo | None: + now = time.time() + state = await self.backend.get(key) + + if state is None: + return None + + water_level = float(state.get("water_level", 0)) + last_update = float(state.get("last_update", now)) + elapsed = now - last_update + water_level = max(0, water_level - elapsed * self.leak_rate) + + remaining = max(0, int(self.burst_size - water_level)) + reset_at = now + water_level / self.leak_rate + + return RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + window_size=self.window_size, + ) + + +class SlidingWindowCounterAlgorithm(BaseAlgorithm): + """Sliding window counter - balance between precision and memory.""" + + async def check(self, key: str) -> tuple[bool, RateLimitInfo]: + now = time.time() + current_window = (now // self.window_size) * self.window_size + previous_window = current_window - self.window_size + window_progress = (now - current_window) / self.window_size + + state = await self.backend.get(key) + + prev_count = 0 + curr_count = 0 + if state is not None: + prev_count = int(state.get("prev_count", 0)) + curr_count = int(state.get("curr_count", 0)) + stored_window = float(state.get("current_window", 0)) + + if stored_window < previous_window: + prev_count = 0 + curr_count = 0 + elif stored_window == previous_window: + prev_count = curr_count + curr_count = 0 + + weighted_count = prev_count * (1 - window_progress) + curr_count + + if weighted_count < self.limit: + curr_count += 1 + allowed = True + retry_after = None + else: + allowed = False + retry_after = self.window_size * (1 - window_progress) + + await self.backend.set( + key, + { + "prev_count": prev_count, + "curr_count": curr_count, + "current_window": current_window, + }, + ttl=self.window_size * 3, + ) + + remaining = max(0, int(self.limit - weighted_count)) + reset_at = current_window + self.window_size + + return allowed, RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + retry_after=retry_after, + window_size=self.window_size, + ) + + async def reset(self, key: str) -> None: + await self.backend.delete(key) + + async def get_state(self, key: str) -> RateLimitInfo | None: + now = time.time() + current_window = (now // self.window_size) * self.window_size + previous_window = current_window - self.window_size + window_progress = (now - current_window) / self.window_size + + state = await self.backend.get(key) + + if state is None: + return None + + prev_count = int(state.get("prev_count", 0)) + curr_count = int(state.get("curr_count", 0)) + stored_window = float(state.get("current_window", 0)) + + if stored_window < previous_window: + prev_count = 0 + curr_count = 0 + elif stored_window == previous_window: + prev_count = curr_count + curr_count = 0 + + weighted_count = prev_count * (1 - window_progress) + curr_count + remaining = max(0, int(self.limit - weighted_count)) + reset_at = current_window + self.window_size + + return RateLimitInfo( + limit=self.limit, + remaining=remaining, + reset_at=reset_at, + window_size=self.window_size, + ) + + +def get_algorithm( + algorithm: Algorithm, + limit: int, + window_size: float, + backend: Backend, + *, + burst_size: int | None = None, +) -> BaseAlgorithm: + """Factory function to create algorithm instances.""" + algorithm_map: dict[Algorithm, type[BaseAlgorithm]] = { + Algorithm.TOKEN_BUCKET: TokenBucketAlgorithm, + Algorithm.SLIDING_WINDOW: SlidingWindowAlgorithm, + Algorithm.FIXED_WINDOW: FixedWindowAlgorithm, + Algorithm.LEAKY_BUCKET: LeakyBucketAlgorithm, + Algorithm.SLIDING_WINDOW_COUNTER: SlidingWindowCounterAlgorithm, + } + + algorithm_class = algorithm_map.get(algorithm) + if algorithm_class is None: + msg = f"Unknown algorithm: {algorithm}" + raise ValueError(msg) + + return algorithm_class(limit, window_size, backend, burst_size=burst_size) diff --git a/fastapi_traffic/core/config.py b/fastapi_traffic/core/config.py new file mode 100644 index 0000000..665a443 --- /dev/null +++ b/fastapi_traffic/core/config.py @@ -0,0 +1,81 @@ +"""Configuration for rate limiting.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable + +from fastapi_traffic.core.algorithms import Algorithm + +if TYPE_CHECKING: + from starlette.requests import Request + + from fastapi_traffic.backends.base import Backend + + +KeyExtractor = Callable[["Request"], str] + + +def default_key_extractor(request: Request) -> str: + """Extract client IP as the default rate limit key.""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip + + if request.client: + return request.client.host + + return "unknown" + + +@dataclass(slots=True) +class RateLimitConfig: + """Configuration for a rate limit rule.""" + + limit: int + window_size: float = 60.0 + algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER + key_prefix: str = "ratelimit" + key_extractor: KeyExtractor = field(default=default_key_extractor) + burst_size: int | None = None + include_headers: bool = True + error_message: str = "Rate limit exceeded" + status_code: int = 429 + skip_on_error: bool = False + cost: int = 1 + exempt_when: Callable[[Request], bool] | None = None + on_blocked: Callable[[Request, Any], Any] | None = None + + def __post_init__(self) -> None: + if self.limit <= 0: + msg = "limit must be positive" + raise ValueError(msg) + if self.window_size <= 0: + msg = "window_size must be positive" + raise ValueError(msg) + if self.cost <= 0: + msg = "cost must be positive" + raise ValueError(msg) + + +@dataclass(slots=True) +class GlobalConfig: + """Global configuration for the rate limiter.""" + + backend: Backend | None = None + enabled: bool = True + default_limit: int = 100 + default_window_size: float = 60.0 + default_algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER + key_prefix: str = "fastapi_traffic" + include_headers: bool = True + error_message: str = "Rate limit exceeded. Please try again later." + status_code: int = 429 + skip_on_error: bool = False + exempt_ips: set[str] = field(default_factory=set) + exempt_paths: set[str] = field(default_factory=set) + headers_prefix: str = "X-RateLimit" diff --git a/fastapi_traffic/core/decorator.py b/fastapi_traffic/core/decorator.py new file mode 100644 index 0000000..5148405 --- /dev/null +++ b/fastapi_traffic/core/decorator.py @@ -0,0 +1,259 @@ +"""Rate limit decorator for FastAPI endpoints.""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Any, Callable, TypeVar, overload + +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import KeyExtractor, RateLimitConfig, default_key_extractor +from fastapi_traffic.core.limiter import get_limiter +from fastapi_traffic.exceptions import RateLimitExceeded + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + +F = TypeVar("F", bound=Callable[..., Any]) + + +@overload +def rate_limit( + limit: int, + *, + window_size: float = ..., + algorithm: Algorithm = ..., + key_prefix: str = ..., + key_extractor: KeyExtractor = ..., + burst_size: int | None = ..., + include_headers: bool = ..., + error_message: str = ..., + status_code: int = ..., + skip_on_error: bool = ..., + cost: int = ..., + exempt_when: Callable[[Request], bool] | None = ..., + on_blocked: Callable[[Request, Any], Any] | None = ..., +) -> Callable[[F], F]: ... + + +@overload +def rate_limit( + limit: int, + window_size: float, + /, +) -> Callable[[F], F]: ... + + +def rate_limit( + limit: int, + window_size: float = 60.0, + *, + algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER, + key_prefix: str = "ratelimit", + key_extractor: KeyExtractor = default_key_extractor, + burst_size: int | None = None, + include_headers: bool = True, + error_message: str = "Rate limit exceeded", + status_code: int = 429, + skip_on_error: bool = False, + cost: int = 1, + exempt_when: Callable[[Request], bool] | None = None, + on_blocked: Callable[[Request, Any], Any] | None = None, +) -> Callable[[F], F]: + """Decorator to apply rate limiting to a FastAPI endpoint. + + Args: + limit: Maximum number of requests allowed in the window. + window_size: Time window in seconds. + algorithm: Rate limiting algorithm to use. + key_prefix: Prefix for the rate limit key. + key_extractor: Function to extract the client identifier from request. + burst_size: Maximum burst size (for token bucket/leaky bucket). + include_headers: Whether to include rate limit headers in response. + error_message: Error message when rate limit is exceeded. + status_code: HTTP status code when rate limit is exceeded. + skip_on_error: Skip rate limiting if backend errors occur. + cost: Cost of each request (default 1). + exempt_when: Function to determine if request should be exempt. + on_blocked: Callback when a request is blocked. + + Returns: + Decorated function with rate limiting applied. + + Example: + ```python + from fastapi import FastAPI + from fastapi_traffic import rate_limit + + app = FastAPI() + + @app.get("/api/resource") + @rate_limit(100, 60) # 100 requests per minute + async def get_resource(): + return {"message": "Hello"} + ``` + """ + config = RateLimitConfig( + limit=limit, + window_size=window_size, + algorithm=algorithm, + key_prefix=key_prefix, + key_extractor=key_extractor, + burst_size=burst_size, + include_headers=include_headers, + error_message=error_message, + status_code=status_code, + skip_on_error=skip_on_error, + cost=cost, + exempt_when=exempt_when, + on_blocked=on_blocked, + ) + + def decorator(func: F) -> F: + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + request = _extract_request(args, kwargs) + if request is None: + return await func(*args, **kwargs) + + limiter = get_limiter() + result = await limiter.hit(request, config) + + response = await func(*args, **kwargs) + + if config.include_headers and hasattr(response, "headers"): + for key, value in result.info.to_headers().items(): + response.headers[key] = value + + return response + + @functools.wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + import asyncio + + return asyncio.get_event_loop().run_until_complete( + async_wrapper(*args, **kwargs) + ) + + if _is_coroutine_function(func): + return async_wrapper # type: ignore[return-value] + return sync_wrapper # type: ignore[return-value] + + return decorator + + +def _extract_request( + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Request | None: + """Extract the Request object from function arguments.""" + from starlette.requests import Request + + for arg in args: + if isinstance(arg, Request): + return arg + + for value in kwargs.values(): + if isinstance(value, Request): + return value + + if "request" in kwargs: + req = kwargs["request"] + if isinstance(req, Request): + return req + + return None + + +def _is_coroutine_function(func: Callable[..., Any]) -> bool: + """Check if a function is a coroutine function.""" + import asyncio + import inspect + + return asyncio.iscoroutinefunction(func) or inspect.iscoroutinefunction(func) + + +class RateLimitDependency: + """FastAPI dependency for rate limiting. + + Example: + ```python + from fastapi import FastAPI, Depends + from fastapi_traffic import RateLimitDependency + + app = FastAPI() + rate_limiter = RateLimitDependency(limit=100, window_size=60) + + @app.get("/api/resource") + async def get_resource(rate_limit_info = Depends(rate_limiter)): + return {"remaining": rate_limit_info.remaining} + ``` + """ + + __slots__ = ("_config",) + + def __init__( + self, + limit: int, + window_size: float = 60.0, + *, + algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER, + key_prefix: str = "ratelimit", + key_extractor: KeyExtractor = default_key_extractor, + burst_size: int | None = None, + error_message: str = "Rate limit exceeded", + status_code: int = 429, + skip_on_error: bool = False, + cost: int = 1, + exempt_when: Callable[[Request], bool] | None = None, + ) -> None: + self._config = RateLimitConfig( + limit=limit, + window_size=window_size, + algorithm=algorithm, + key_prefix=key_prefix, + key_extractor=key_extractor, + burst_size=burst_size, + include_headers=True, + error_message=error_message, + status_code=status_code, + skip_on_error=skip_on_error, + cost=cost, + exempt_when=exempt_when, + ) + + async def __call__(self, request: Request) -> Any: + """Check rate limit and return info.""" + limiter = get_limiter() + result = await limiter.hit(request, self._config) + return result.info + + +def create_rate_limit_response( + exc: RateLimitExceeded, + *, + include_headers: bool = True, +) -> Response: + """Create a rate limit exceeded response. + + Args: + exc: The RateLimitExceeded exception. + include_headers: Whether to include rate limit headers. + + Returns: + A JSONResponse with rate limit information. + """ + from starlette.responses import JSONResponse + + headers: dict[str, str] = {} + if include_headers and exc.limit_info is not None: + headers = exc.limit_info.to_headers() + + return JSONResponse( + status_code=429, + content={ + "detail": exc.message, + "retry_after": exc.retry_after, + }, + headers=headers, + ) diff --git a/fastapi_traffic/core/limiter.py b/fastapi_traffic/core/limiter.py new file mode 100644 index 0000000..31f4e43 --- /dev/null +++ b/fastapi_traffic/core/limiter.py @@ -0,0 +1,301 @@ +"""Core rate limiter implementation.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.core.algorithms import Algorithm, BaseAlgorithm, get_algorithm +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig +from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult +from fastapi_traffic.exceptions import BackendError, RateLimitExceeded + +if TYPE_CHECKING: + from starlette.requests import Request + + from fastapi_traffic.backends.base import Backend + +logger = logging.getLogger(__name__) + + +class RateLimiter: + """Main rate limiter class that manages rate limiting logic.""" + + __slots__ = ("_config", "_backend", "_algorithms", "_initialized") + + def __init__( + self, + backend: Backend | None = None, + *, + config: GlobalConfig | None = None, + ) -> None: + """Initialize the rate limiter. + + Args: + backend: Storage backend for rate limit data. + config: Global configuration options. + """ + self._config = config or GlobalConfig() + self._backend = backend or self._config.backend or MemoryBackend() + self._algorithms: dict[str, BaseAlgorithm] = {} + self._initialized = False + + @property + def backend(self) -> Backend: + """Get the storage backend.""" + return self._backend + + @property + def config(self) -> GlobalConfig: + """Get the global configuration.""" + return self._config + + async def initialize(self) -> None: + """Initialize the rate limiter and backend.""" + if self._initialized: + return + + if hasattr(self._backend, "initialize"): + await self._backend.initialize() # type: ignore[union-attr] + + if hasattr(self._backend, "start_cleanup"): + await self._backend.start_cleanup() # type: ignore[union-attr] + + self._initialized = True + + async def close(self) -> None: + """Close the rate limiter and cleanup resources.""" + await self._backend.close() + self._algorithms.clear() + self._initialized = False + + def _get_algorithm( + self, + limit: int, + window_size: float, + algorithm: Algorithm, + burst_size: int | None = None, + ) -> BaseAlgorithm: + """Get or create an algorithm instance.""" + cache_key = f"{algorithm.value}:{limit}:{window_size}:{burst_size}" + if cache_key not in self._algorithms: + self._algorithms[cache_key] = get_algorithm( + algorithm, + limit, + window_size, + self._backend, + burst_size=burst_size, + ) + return self._algorithms[cache_key] + + def _build_key( + self, + request: Request, + config: RateLimitConfig, + identifier: str | None = None, + ) -> str: + """Build the rate limit key for a request.""" + if identifier: + client_id = identifier + else: + client_id = config.key_extractor(request) + + path = request.url.path + method = request.method + + return f"{self._config.key_prefix}:{config.key_prefix}:{method}:{path}:{client_id}" + + def _is_exempt(self, request: Request, config: RateLimitConfig) -> bool: + """Check if the request is exempt from rate limiting.""" + if not self._config.enabled: + return True + + if config.exempt_when is not None and config.exempt_when(request): + return True + + client_ip = config.key_extractor(request) + if client_ip in self._config.exempt_ips: + return True + + if request.url.path in self._config.exempt_paths: + return True + + return False + + async def check( + self, + request: Request, + config: RateLimitConfig, + *, + identifier: str | None = None, + cost: int | None = None, + ) -> RateLimitResult: + """Check if a request is allowed under the rate limit. + + Args: + request: The incoming request. + config: Rate limit configuration for this endpoint. + identifier: Optional custom identifier override. + cost: Optional cost override for this request. + + Returns: + RateLimitResult with allowed status and limit info. + """ + if not self._initialized: + await self.initialize() + + if self._is_exempt(request, config): + return RateLimitResult( + allowed=True, + info=RateLimitInfo( + limit=config.limit, + remaining=config.limit, + reset_at=0, + window_size=config.window_size, + ), + key="exempt", + ) + + key = self._build_key(request, config, identifier) + actual_cost = cost or config.cost + + try: + algorithm = self._get_algorithm( + config.limit, + config.window_size, + config.algorithm, + config.burst_size, + ) + + info: RateLimitInfo | None = None + for _ in range(actual_cost): + allowed, info = await algorithm.check(key) + if not allowed: + return RateLimitResult(allowed=False, info=info, key=key) + + if info is None: + info = RateLimitInfo( + limit=config.limit, + remaining=config.limit, + reset_at=0, + window_size=config.window_size, + ) + return RateLimitResult(allowed=True, info=info, key=key) + + except BackendError as e: + logger.warning("Backend error during rate limit check: %s", e) + if config.skip_on_error: + return RateLimitResult( + allowed=True, + info=RateLimitInfo( + limit=config.limit, + remaining=config.limit, + reset_at=0, + window_size=config.window_size, + ), + key=key, + ) + raise + + async def hit( + self, + request: Request, + config: RateLimitConfig, + *, + identifier: str | None = None, + cost: int | None = None, + ) -> RateLimitResult: + """Check rate limit and raise exception if exceeded. + + Args: + request: The incoming request. + config: Rate limit configuration for this endpoint. + identifier: Optional custom identifier override. + cost: Optional cost override for this request. + + Returns: + RateLimitResult if allowed. + + Raises: + RateLimitExceeded: If the rate limit is exceeded. + """ + result = await self.check(request, config, identifier=identifier, cost=cost) + + if not result.allowed: + if config.on_blocked is not None: + config.on_blocked(request, result) + + raise RateLimitExceeded( + config.error_message, + retry_after=result.info.retry_after, + limit_info=result.info, + ) + + return result + + async def reset( + self, + request: Request, + config: RateLimitConfig, + *, + identifier: str | None = None, + ) -> None: + """Reset the rate limit for a specific key. + + Args: + request: The request to reset limits for. + config: Rate limit configuration. + identifier: Optional custom identifier override. + """ + key = self._build_key(request, config, identifier) + algorithm = self._get_algorithm( + config.limit, + config.window_size, + config.algorithm, + config.burst_size, + ) + await algorithm.reset(key) + + async def get_state( + self, + request: Request, + config: RateLimitConfig, + *, + identifier: str | None = None, + ) -> RateLimitInfo | None: + """Get the current rate limit state without consuming a token. + + Args: + request: The request to check. + config: Rate limit configuration. + identifier: Optional custom identifier override. + + Returns: + Current rate limit info or None if no state exists. + """ + key = self._build_key(request, config, identifier) + algorithm = self._get_algorithm( + config.limit, + config.window_size, + config.algorithm, + config.burst_size, + ) + return await algorithm.get_state(key) + + +_default_limiter: RateLimiter | None = None + + +def get_limiter() -> RateLimiter: + """Get the default rate limiter instance.""" + global _default_limiter + if _default_limiter is None: + _default_limiter = RateLimiter() + return _default_limiter + + +def set_limiter(limiter: RateLimiter) -> None: + """Set the default rate limiter instance.""" + global _default_limiter + _default_limiter = limiter diff --git a/fastapi_traffic/core/models.py b/fastapi_traffic/core/models.py new file mode 100644 index 0000000..c005a0e --- /dev/null +++ b/fastapi_traffic/core/models.py @@ -0,0 +1,89 @@ +"""Data models for rate limiting.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class KeyType(str, Enum): + """Type of key extraction for rate limiting.""" + + IP = "ip" + USER = "user" + API_KEY = "api_key" + ENDPOINT = "endpoint" + CUSTOM = "custom" + + +@dataclass(frozen=True, slots=True) +class RateLimitInfo: + """Information about the current rate limit state.""" + + limit: int + remaining: int + reset_at: float + retry_after: float | None = None + window_size: float = 60.0 + + def to_headers(self) -> dict[str, str]: + """Convert rate limit info to HTTP headers.""" + headers: dict[str, str] = { + "X-RateLimit-Limit": str(self.limit), + "X-RateLimit-Remaining": str(max(0, self.remaining)), + "X-RateLimit-Reset": str(int(self.reset_at)), + } + if self.retry_after is not None: + headers["Retry-After"] = str(int(self.retry_after)) + return headers + + +@dataclass(frozen=True, slots=True) +class RateLimitResult: + """Result of a rate limit check.""" + + allowed: bool + info: RateLimitInfo + key: str + + +@dataclass(slots=True) +class TokenBucketState: + """State for token bucket algorithm.""" + + tokens: float + last_update: float + + +@dataclass(slots=True) +class SlidingWindowState: + """State for sliding window algorithm.""" + + timestamps: list[float] = field(default_factory=list) + count: int = 0 + + +@dataclass(slots=True) +class FixedWindowState: + """State for fixed window algorithm.""" + + count: int + window_start: float + + +@dataclass(slots=True) +class LeakyBucketState: + """State for leaky bucket algorithm.""" + + water_level: float + last_update: float + + +@dataclass(frozen=True, slots=True) +class BackendRecord: + """Generic record stored in backends.""" + + key: str + data: dict[str, Any] + expires_at: float diff --git a/fastapi_traffic/exceptions.py b/fastapi_traffic/exceptions.py new file mode 100644 index 0000000..102143e --- /dev/null +++ b/fastapi_traffic/exceptions.py @@ -0,0 +1,50 @@ +"""Custom exceptions for FastAPI Traffic rate limiter.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi_traffic.core.models import RateLimitInfo + + +class FastAPITrafficError(Exception): + """Base exception for all FastAPI Traffic errors.""" + + pass + + +class RateLimitExceeded(FastAPITrafficError): + """Raised when a rate limit has been exceeded.""" + + def __init__( + self, + message: str = "Rate limit exceeded", + *, + retry_after: float | None = None, + limit_info: RateLimitInfo | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.retry_after = retry_after + self.limit_info = limit_info + + +class BackendError(FastAPITrafficError): + """Raised when a backend operation fails.""" + + def __init__( + self, + message: str = "Backend operation failed", + *, + original_error: Exception | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.original_error = original_error + + +class ConfigurationError(FastAPITrafficError): + """Raised when there is a configuration error.""" + + pass diff --git a/fastapi_traffic/middleware.py b/fastapi_traffic/middleware.py new file mode 100644 index 0000000..8583d05 --- /dev/null +++ b/fastapi_traffic/middleware.py @@ -0,0 +1,184 @@ +"""Rate limiting middleware for Starlette/FastAPI applications.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.core.algorithms import Algorithm +from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig, default_key_extractor +from fastapi_traffic.core.limiter import RateLimiter +from fastapi_traffic.exceptions import RateLimitExceeded + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + from starlette.types import ASGIApp + + from fastapi_traffic.backends.base import Backend + +logger = logging.getLogger(__name__) + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Middleware for global rate limiting across all endpoints.""" + + def __init__( + self, + app: ASGIApp, + *, + limit: int = 100, + window_size: float = 60.0, + algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER, + backend: Backend | None = None, + key_prefix: str = "middleware", + include_headers: bool = True, + error_message: str = "Rate limit exceeded. Please try again later.", + status_code: int = 429, + skip_on_error: bool = False, + exempt_paths: set[str] | None = None, + exempt_ips: set[str] | None = None, + key_extractor: Callable[[Request], str] = default_key_extractor, + ) -> None: + """Initialize the rate limit middleware. + + Args: + app: The ASGI application. + limit: Maximum requests per window. + window_size: Time window in seconds. + algorithm: Rate limiting algorithm. + backend: Storage backend (defaults to MemoryBackend). + key_prefix: Prefix for rate limit keys. + include_headers: Include rate limit headers in response. + error_message: Error message when rate limited. + status_code: HTTP status code when rate limited. + skip_on_error: Skip rate limiting on backend errors. + exempt_paths: Paths to exempt from rate limiting. + exempt_ips: IP addresses to exempt from rate limiting. + key_extractor: Function to extract client identifier. + """ + super().__init__(app) + + self._backend = backend or MemoryBackend() + self._config = RateLimitConfig( + limit=limit, + window_size=window_size, + algorithm=algorithm, + key_prefix=key_prefix, + key_extractor=key_extractor, + include_headers=include_headers, + error_message=error_message, + status_code=status_code, + skip_on_error=skip_on_error, + ) + + global_config = GlobalConfig( + backend=self._backend, + exempt_paths=exempt_paths or set(), + exempt_ips=exempt_ips or set(), + ) + + self._limiter = RateLimiter(self._backend, config=global_config) + self._include_headers = include_headers + self._error_message = error_message + self._status_code = status_code + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + """Process the request with rate limiting.""" + try: + result = await self._limiter.check(request, self._config) + + if not result.allowed: + return self._create_rate_limit_response(result) + + response = await call_next(request) + + if self._include_headers: + for key, value in result.info.to_headers().items(): + response.headers[key] = value + + return response + + except RateLimitExceeded as exc: + return JSONResponse( + status_code=self._status_code, + content={ + "detail": exc.message, + "retry_after": exc.retry_after, + }, + headers=exc.limit_info.to_headers() if exc.limit_info else {}, + ) + + except Exception as e: + logger.exception("Error in rate limit middleware: %s", e) + if self._config.skip_on_error: + return await call_next(request) + raise + + def _create_rate_limit_response(self, result: object) -> JSONResponse: + """Create a rate limit exceeded response.""" + from fastapi_traffic.core.models import RateLimitResult + + if isinstance(result, RateLimitResult): + headers = result.info.to_headers() + retry_after = result.info.retry_after + else: + headers = {} + retry_after = None + + return JSONResponse( + status_code=self._status_code, + content={ + "detail": self._error_message, + "retry_after": retry_after, + }, + headers=headers, + ) + + +class SlidingWindowMiddleware(RateLimitMiddleware): + """Convenience middleware using sliding window algorithm.""" + + def __init__( + self, + app: ASGIApp, + *, + limit: int = 100, + window_size: float = 60.0, + **kwargs: object, + ) -> None: + super().__init__( + app, + limit=limit, + window_size=window_size, + algorithm=Algorithm.SLIDING_WINDOW, + **kwargs, # type: ignore[arg-type] + ) + + +class TokenBucketMiddleware(RateLimitMiddleware): + """Convenience middleware using token bucket algorithm.""" + + def __init__( + self, + app: ASGIApp, + *, + limit: int = 100, + window_size: float = 60.0, + **kwargs: object, + ) -> None: + super().__init__( + app, + limit=limit, + window_size=window_size, + algorithm=Algorithm.TOKEN_BUCKET, + **kwargs, # type: ignore[arg-type] + ) diff --git a/fastapi_traffic/py.typed b/fastapi_traffic/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..85fe1fe --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from fastapi-traffic!") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8fa716f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +[project] +name = "fastapi-traffic" +version = "0.1.0" +description = "Production-grade rate limiting for FastAPI with multiple algorithms and backends" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "zanewalker", email="bereckobrian@gmail.com" }] +keywords = ["fastapi", "rate-limit", "rate-limiting", "throttle", "api", "redis", "sqlite"] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "starlette>=0.27.0", +] + +[project.optional-dependencies] +redis = ["redis>=5.0.0"] +fastapi = ["fastapi>=0.100.0"] +all = ["redis>=5.0.0", "fastapi>=0.100.0"] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=4.0.0", + "httpx>=0.27.0", + "ruff>=0.4.0", + "pyright>=1.1.350", + "redis>=5.0.0", + "fastapi>=0.100.0", + "uvicorn>=0.29.0", +] + +[project.urls] +Homepage = "https://github.com/fastapi-traffic/fastapi-traffic" +Documentation = "https://github.com/fastapi-traffic/fastapi-traffic#readme" +Repository = "https://github.com/fastapi-traffic/fastapi-traffic" +Issues = "https://github.com/fastapi-traffic/fastapi-traffic/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["fastapi_traffic"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "PTH", # flake8-use-pathlib + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults + "B904", # raise without from inside except +] + +[tool.ruff.lint.isort] +known-first-party = ["fastapi_traffic"] + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "strict" +reportMissingTypeStubs = false +reportUnknownMemberType = false +reportUnknownArgumentType = false +reportUnknownVariableType = false +reportUnknownParameterType = false +reportMissingImports = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short" + +[dependency-groups] +dev = [ + "fastapi>=0.128.0", + "pytest>=9.0.2", + "uvicorn>=0.40.0", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..67e8e24 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for fastapi-traffic.""" diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py new file mode 100644 index 0000000..0f295d2 --- /dev/null +++ b/tests/test_algorithms.py @@ -0,0 +1,211 @@ +"""Tests for rate limiting algorithms.""" + +from __future__ import annotations + +from typing import AsyncGenerator + +import pytest + +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.core.algorithms import ( + Algorithm, + FixedWindowAlgorithm, + LeakyBucketAlgorithm, + SlidingWindowAlgorithm, + SlidingWindowCounterAlgorithm, + TokenBucketAlgorithm, + get_algorithm, +) + + +@pytest.fixture +async def backend() -> AsyncGenerator[MemoryBackend, None]: + """Create a memory backend for testing.""" + backend = MemoryBackend() + yield backend + await backend.close() + + +class TestTokenBucketAlgorithm: + """Tests for TokenBucketAlgorithm.""" + + async def test_allows_requests_within_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests within limit are allowed.""" + algo = TokenBucketAlgorithm(10, 60.0, backend) + + for i in range(10): + allowed, _ = await algo.check(f"key_{i % 2}") + assert allowed, f"Request {i} should be allowed" + + async def test_blocks_requests_over_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests over limit are blocked.""" + algo = TokenBucketAlgorithm(3, 60.0, backend) + + for _ in range(3): + allowed, _ = await algo.check("test_key") + assert allowed + + allowed, info = await algo.check("test_key") + assert not allowed + assert info.retry_after is not None + assert info.retry_after > 0 + + async def test_reset(self, backend: MemoryBackend) -> None: + """Test reset functionality.""" + algo = TokenBucketAlgorithm(3, 60.0, backend) + + for _ in range(3): + await algo.check("test_key") + + allowed, _ = await algo.check("test_key") + assert not allowed + + await algo.reset("test_key") + + allowed, _ = await algo.check("test_key") + assert allowed + + +class TestSlidingWindowAlgorithm: + """Tests for SlidingWindowAlgorithm.""" + + async def test_allows_requests_within_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests within limit are allowed.""" + algo = SlidingWindowAlgorithm(5, 60.0, backend) + + for _ in range(5): + allowed, _ = await algo.check("test_key") + assert allowed + + async def test_blocks_requests_over_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests over limit are blocked.""" + algo = SlidingWindowAlgorithm(3, 60.0, backend) + + for _ in range(3): + allowed, _ = await algo.check("test_key") + assert allowed + + allowed, info = await algo.check("test_key") + assert not allowed + assert info.remaining == 0 + + +class TestFixedWindowAlgorithm: + """Tests for FixedWindowAlgorithm.""" + + async def test_allows_requests_within_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests within limit are allowed.""" + algo = FixedWindowAlgorithm(5, 60.0, backend) + + for _ in range(5): + allowed, _ = await algo.check("test_key") + assert allowed + + async def test_blocks_requests_over_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests over limit are blocked.""" + algo = FixedWindowAlgorithm(3, 60.0, backend) + + for _ in range(3): + allowed, _ = await algo.check("test_key") + assert allowed + + allowed, info = await algo.check("test_key") + assert not allowed + assert info.remaining == 0 + + +class TestLeakyBucketAlgorithm: + """Tests for LeakyBucketAlgorithm.""" + + async def test_allows_requests_within_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests within limit are allowed.""" + algo = LeakyBucketAlgorithm(5, 60.0, backend) + + for _ in range(5): + allowed, _ = await algo.check("test_key") + assert allowed + + async def test_blocks_requests_over_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests over limit are blocked.""" + algo = LeakyBucketAlgorithm(3, 60.0, backend) + + for _ in range(3): + allowed, _ = await algo.check("test_key") + assert allowed + + allowed, _ = await algo.check("test_key") + assert not allowed + + +class TestSlidingWindowCounterAlgorithm: + """Tests for SlidingWindowCounterAlgorithm.""" + + async def test_allows_requests_within_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests within limit are allowed.""" + algo = SlidingWindowCounterAlgorithm(5, 60.0, backend) + + for _ in range(5): + allowed, _ = await algo.check("test_key") + assert allowed + + async def test_blocks_requests_over_limit( + self, backend: MemoryBackend + ) -> None: + """Test that requests over limit are blocked.""" + algo = SlidingWindowCounterAlgorithm(3, 60.0, backend) + + for _ in range(3): + allowed, _ = await algo.check("test_key") + assert allowed + + allowed, _ = await algo.check("test_key") + assert not allowed + + +class TestGetAlgorithm: + """Tests for get_algorithm factory function.""" + + async def test_get_token_bucket(self, backend: MemoryBackend) -> None: + """Test getting token bucket algorithm.""" + algo = get_algorithm(Algorithm.TOKEN_BUCKET, 10, 60.0, backend) + assert isinstance(algo, TokenBucketAlgorithm) + + async def test_get_sliding_window(self, backend: MemoryBackend) -> None: + """Test getting sliding window algorithm.""" + algo = get_algorithm(Algorithm.SLIDING_WINDOW, 10, 60.0, backend) + assert isinstance(algo, SlidingWindowAlgorithm) + + async def test_get_fixed_window(self, backend: MemoryBackend) -> None: + """Test getting fixed window algorithm.""" + algo = get_algorithm(Algorithm.FIXED_WINDOW, 10, 60.0, backend) + assert isinstance(algo, FixedWindowAlgorithm) + + async def test_get_leaky_bucket(self, backend: MemoryBackend) -> None: + """Test getting leaky bucket algorithm.""" + algo = get_algorithm(Algorithm.LEAKY_BUCKET, 10, 60.0, backend) + assert isinstance(algo, LeakyBucketAlgorithm) + + async def test_get_sliding_window_counter( + self, backend: MemoryBackend + ) -> None: + """Test getting sliding window counter algorithm.""" + algo = get_algorithm(Algorithm.SLIDING_WINDOW_COUNTER, 10, 60.0, backend) + assert isinstance(algo, SlidingWindowCounterAlgorithm) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..a115665 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,143 @@ +"""Tests for rate limit backends.""" + +from __future__ import annotations + +import asyncio +from typing import AsyncGenerator + +import pytest + +from fastapi_traffic.backends.memory import MemoryBackend +from fastapi_traffic.backends.sqlite import SQLiteBackend + + +class TestMemoryBackend: + """Tests for MemoryBackend.""" + + @pytest.fixture + async def backend(self) -> AsyncGenerator[MemoryBackend, None]: + """Create a memory backend for testing.""" + backend = MemoryBackend(max_size=100, cleanup_interval=1.0) + yield backend + await backend.close() + + async def test_set_and_get(self, backend: MemoryBackend) -> None: + """Test basic set and get operations.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + result = await backend.get("test_key") + assert result is not None + assert result["count"] == 5 + + async def test_get_nonexistent(self, backend: MemoryBackend) -> None: + """Test getting a nonexistent key.""" + result = await backend.get("nonexistent") + assert result is None + + async def test_delete(self, backend: MemoryBackend) -> None: + """Test delete operation.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + await backend.delete("test_key") + result = await backend.get("test_key") + assert result is None + + async def test_exists(self, backend: MemoryBackend) -> None: + """Test exists operation.""" + assert not await backend.exists("test_key") + await backend.set("test_key", {"count": 5}, ttl=60.0) + assert await backend.exists("test_key") + + async def test_increment(self, backend: MemoryBackend) -> None: + """Test increment operation.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + result = await backend.increment("test_key", 3) + assert result == 8 + + async def test_clear(self, backend: MemoryBackend) -> None: + """Test clear operation.""" + await backend.set("key1", {"count": 1}, ttl=60.0) + await backend.set("key2", {"count": 2}, ttl=60.0) + await backend.clear() + assert not await backend.exists("key1") + assert not await backend.exists("key2") + + async def test_ttl_expiration(self, backend: MemoryBackend) -> None: + """Test that entries expire after TTL.""" + await backend.set("test_key", {"count": 5}, ttl=0.1) + await asyncio.sleep(0.2) + result = await backend.get("test_key") + assert result is None + + async def test_lru_eviction(self) -> None: + """Test LRU eviction when max size is reached.""" + backend = MemoryBackend(max_size=3) + try: + await backend.set("key1", {"v": 1}, ttl=60.0) + await backend.set("key2", {"v": 2}, ttl=60.0) + await backend.set("key3", {"v": 3}, ttl=60.0) + await backend.set("key4", {"v": 4}, ttl=60.0) + + assert not await backend.exists("key1") + assert await backend.exists("key2") + assert await backend.exists("key3") + assert await backend.exists("key4") + finally: + await backend.close() + + +class TestSQLiteBackend: + """Tests for SQLiteBackend.""" + + @pytest.fixture + async def backend(self) -> AsyncGenerator[SQLiteBackend, None]: + """Create an in-memory SQLite backend for testing.""" + backend = SQLiteBackend(":memory:", cleanup_interval=1.0) + await backend.initialize() + yield backend + await backend.close() + + async def test_set_and_get(self, backend: SQLiteBackend) -> None: + """Test basic set and get operations.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + result = await backend.get("test_key") + assert result is not None + assert result["count"] == 5 + + async def test_get_nonexistent(self, backend: SQLiteBackend) -> None: + """Test getting a nonexistent key.""" + result = await backend.get("nonexistent") + assert result is None + + async def test_delete(self, backend: SQLiteBackend) -> None: + """Test delete operation.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + await backend.delete("test_key") + result = await backend.get("test_key") + assert result is None + + async def test_exists(self, backend: SQLiteBackend) -> None: + """Test exists operation.""" + assert not await backend.exists("test_key") + await backend.set("test_key", {"count": 5}, ttl=60.0) + assert await backend.exists("test_key") + + async def test_increment(self, backend: SQLiteBackend) -> None: + """Test increment operation.""" + await backend.set("test_key", {"count": 5}, ttl=60.0) + result = await backend.increment("test_key", 3) + assert result == 8 + + async def test_clear(self, backend: SQLiteBackend) -> None: + """Test clear operation.""" + await backend.set("key1", {"count": 1}, ttl=60.0) + await backend.set("key2", {"count": 2}, ttl=60.0) + await backend.clear() + assert not await backend.exists("key1") + assert not await backend.exists("key2") + + async def test_get_stats(self, backend: SQLiteBackend) -> None: + """Test get_stats operation.""" + await backend.set("key1", {"count": 1}, ttl=60.0) + await backend.set("key2", {"count": 2}, ttl=60.0) + stats = await backend.get_stats() + assert stats["total_entries"] == 2 + assert stats["active_entries"] == 2 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d896f6e --- /dev/null +++ b/uv.lock @@ -0,0 +1,696 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "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 = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "fastapi-traffic" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "starlette" }, +] + +[package.optional-dependencies] +all = [ + { name = "fastapi" }, + { name = "redis" }, +] +dev = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "redis" }, + { name = "ruff" }, + { name = "uvicorn" }, +] +fastapi = [ + { name = "fastapi" }, +] +redis = [ + { name = "redis" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi" }, + { name = "pytest" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100.0" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.100.0" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "redis", marker = "extra == 'all'", specifier = ">=5.0.0" }, + { name = "redis", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "starlette", specifier = ">=0.27.0" }, + { name = "uvicorn", marker = "extra == 'dev'", specifier = ">=0.29.0" }, +] +provides-extras = ["redis", "fastapi", "all", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.128.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "uvicorn", specifier = ">=0.40.0" }, +] + +[[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.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[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.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[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 = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.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/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[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.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +]