diff --git a/.gitignore b/.gitignore index 022b770..e846240 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ things-todo.md .ruff_cache .qodo .pytest_cache -.vscode/ \ No newline at end of file +.vscode + +docs/_build +docs/_static +docs/_templates \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d024dbc..07ce12a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,36 @@ All notable changes to fastapi-traffic will be documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-17 + +### Added + +- Expanded example scripts with improved docstrings and usage patterns +- New `00_basic_usage.py` example for getting started quickly + +### Changed + +- Refactored Redis backend connection handling for improved reliability +- Updated algorithm implementations with cleaner type annotations +- Improved config loader validation with stricter Pydantic schemas +- Enhanced decorator and middleware error handling +- Reorganized examples directory structure (removed legacy `basic_usage.py`) + +### Fixed + +- Redis backend connection pool management edge cases +- Type annotation inconsistencies across core modules + +## [0.2.1] - 2026-03-12 + +### Fixed + +- Test assertion bug in `test_load_rate_limit_config_from_env_missing_limit` test case within `test_config_loader.py`. + ## [0.2.0] - 2026-02-04 ### Added + - **Configuration Loader** - Load rate limiting configuration from external files: - `ConfigLoader` class for loading `RateLimitConfig` and `GlobalConfig` - Support for `.env` files with `FASTAPI_TRAFFIC_*` prefixed variables @@ -28,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `httpx` and `pytest-asyncio` as dev dependencies for testing ### Changed + - Improved documentation in README.md and DEVELOPMENT.md - Added `asyncio_default_fixture_loop_scope` config for pytest-asyncio compatibility @@ -36,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Initial release. ### Added + - Core rate limiting with `@rate_limit` decorator - Five algorithms: Token Bucket, Sliding Window, Fixed Window, Leaky Bucket, Sliding Window Counter - Three storage backends: Memory (default), SQLite (persistent), Redis (distributed) diff --git a/README.md b/README.md index 2fcb0c9..95badc0 100644 --- a/README.md +++ b/README.md @@ -188,16 +188,19 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded): ## 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 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5c2dc9c --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,14 @@ +# Minimal makefile for Sphinx documentation + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/advanced/distributed-systems.rst b/docs/advanced/distributed-systems.rst new file mode 100644 index 0000000..4988545 --- /dev/null +++ b/docs/advanced/distributed-systems.rst @@ -0,0 +1,319 @@ +Distributed Systems +=================== + +Running rate limiting across multiple application instances requires careful +consideration. This guide covers the patterns and pitfalls. + +The Challenge +------------- + +In a distributed system, you might have: + +- Multiple application instances behind a load balancer +- Kubernetes pods that scale up and down +- Serverless functions that run independently + +Each instance needs to share rate limit state. Otherwise, a client could make +100 requests to instance A and another 100 to instance B, effectively bypassing +a 100 request limit. + +Redis: The Standard Solution +---------------------------- + +Redis is the go-to choice for distributed rate limiting: + +.. code-block:: python + + from fastapi import FastAPI + from fastapi_traffic import RateLimiter + from fastapi_traffic.backends.redis import RedisBackend + from fastapi_traffic.core.limiter import set_limiter + + app = FastAPI() + + @app.on_event("startup") + async def startup(): + backend = await RedisBackend.from_url( + "redis://redis-server:6379/0", + key_prefix="myapp:ratelimit", + ) + limiter = RateLimiter(backend) + set_limiter(limiter) + await limiter.initialize() + + @app.on_event("shutdown") + async def shutdown(): + limiter = get_limiter() + await limiter.close() + +All instances connect to the same Redis server and share state. + +High Availability Redis +----------------------- + +For production, you'll want Redis with high availability: + +**Redis Sentinel:** + +.. code-block:: python + + backend = await RedisBackend.from_url( + "redis://sentinel1:26379,sentinel2:26379,sentinel3:26379/0", + sentinel_master="mymaster", + ) + +**Redis Cluster:** + +.. code-block:: python + + backend = await RedisBackend.from_url( + "redis://node1:6379,node2:6379,node3:6379/0", + ) + +Atomic Operations +----------------- + +Race conditions are a real concern in distributed systems. Consider this scenario: + +1. Instance A reads: 99 requests made +2. Instance B reads: 99 requests made +3. Instance A writes: 100 requests (allows request) +4. Instance B writes: 100 requests (allows request) + +Now you've allowed 101 requests when the limit was 100. + +FastAPI Traffic's Redis backend uses Lua scripts to make operations atomic: + +.. code-block:: lua + + -- Simplified example of atomic check-and-increment + local current = redis.call('GET', KEYS[1]) + if current and tonumber(current) >= limit then + return 0 -- Reject + end + redis.call('INCR', KEYS[1]) + return 1 -- Allow + +The entire check-and-update happens in a single Redis operation. + +Network Latency +--------------- + +Redis adds network latency to every request. Some strategies to minimize impact: + +**1. Connection pooling (automatic):** + +The Redis backend maintains a connection pool, so you're not creating new +connections for each request. + +**2. Local caching:** + +For very high-traffic endpoints, consider a two-tier approach: + +.. code-block:: python + + from fastapi_traffic import MemoryBackend, RateLimiter + + # Local memory backend for fast path + local_backend = MemoryBackend() + local_limiter = RateLimiter(local_backend) + + # Redis backend for distributed state + redis_backend = await RedisBackend.from_url("redis://localhost:6379/0") + distributed_limiter = RateLimiter(redis_backend) + + async def check_rate_limit(request: Request, config: RateLimitConfig): + # Quick local check (may allow some extra requests) + local_result = await local_limiter.check(request, config) + if not local_result.allowed: + return local_result + + # Authoritative distributed check + return await distributed_limiter.check(request, config) + +**3. Skip on error:** + +If Redis latency is causing issues, you might prefer to allow requests through +rather than block: + +.. code-block:: python + + @rate_limit(100, 60, skip_on_error=True) + async def endpoint(request: Request): + return {"status": "ok"} + +Handling Redis Failures +----------------------- + +What happens when Redis goes down? + +**Fail closed (default):** + +Requests fail. This is safer but impacts availability. + +**Fail open:** + +Allow requests through: + +.. code-block:: python + + @rate_limit(100, 60, skip_on_error=True) + +**Circuit breaker pattern:** + +Implement a circuit breaker to avoid hammering a failing Redis: + +.. code-block:: python + + import time + + class CircuitBreaker: + def __init__(self, failure_threshold=5, reset_timeout=60): + self.failures = 0 + self.threshold = failure_threshold + self.reset_timeout = reset_timeout + self.last_failure = 0 + self.open = False + + def record_failure(self): + self.failures += 1 + self.last_failure = time.time() + if self.failures >= self.threshold: + self.open = True + + def record_success(self): + self.failures = 0 + self.open = False + + def should_allow(self) -> bool: + if not self.open: + return True + # Check if we should try again + if time.time() - self.last_failure > self.reset_timeout: + return True + return False + +Kubernetes Deployment +--------------------- + +Here's a typical Kubernetes setup: + +.. code-block:: yaml + + # redis-deployment.yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: redis + spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + --- + apiVersion: v1 + kind: Service + metadata: + name: redis + spec: + selector: + app: redis + ports: + - port: 6379 + +.. code-block:: yaml + + # app-deployment.yaml + apiVersion: apps/v1 + kind: Deployment + metadata: + name: api + spec: + replicas: 3 + selector: + matchLabels: + app: api + template: + spec: + containers: + - name: api + image: myapp:latest + env: + - name: REDIS_URL + value: "redis://redis:6379/0" + +Your app connects to Redis via the service name: + +.. code-block:: python + + import os + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + backend = await RedisBackend.from_url(redis_url) + +Monitoring +---------- + +Keep an eye on: + +1. **Redis latency:** High latency means slow requests +2. **Redis memory:** Rate limit data shouldn't use much, but monitor it +3. **Connection count:** Make sure you're not exhausting connections +4. **Rate limit hits:** Track how often clients are being limited + +.. code-block:: python + + import logging + + logger = logging.getLogger(__name__) + + def on_rate_limited(request: Request, result): + logger.info( + "Rate limited: client=%s path=%s remaining=%d", + request.client.host, + request.url.path, + result.info.remaining, + ) + + @rate_limit(100, 60, on_blocked=on_rate_limited) + async def endpoint(request: Request): + return {"status": "ok"} + +Testing Distributed Rate Limits +------------------------------- + +Testing distributed behavior is tricky. Here's an approach: + +.. code-block:: python + + import asyncio + import httpx + + async def test_distributed_limit(): + """Simulate requests from multiple 'instances'.""" + async with httpx.AsyncClient() as client: + # Fire 150 requests concurrently + tasks = [ + client.get("http://localhost:8000/api/data") + for _ in range(150) + ] + responses = await asyncio.gather(*tasks) + + # Count successes and rate limits + successes = sum(1 for r in responses if r.status_code == 200) + limited = sum(1 for r in responses if r.status_code == 429) + + print(f"Successes: {successes}, Rate limited: {limited}") + # With a limit of 100, expect ~100 successes and ~50 limited + + asyncio.run(test_distributed_limit()) diff --git a/docs/advanced/performance.rst b/docs/advanced/performance.rst new file mode 100644 index 0000000..24e07ab --- /dev/null +++ b/docs/advanced/performance.rst @@ -0,0 +1,291 @@ +Performance +=========== + +FastAPI Traffic is designed to be fast. But when you're handling thousands of +requests per second, every microsecond counts. Here's how to squeeze out the +best performance. + +Baseline Performance +-------------------- + +On typical hardware, you can expect: + +- **Memory backend:** ~0.01ms per check +- **SQLite backend:** ~0.1ms per check +- **Redis backend:** ~1ms per check (network dependent) + +For most applications, this overhead is negligible compared to your actual +business logic. + +Choosing the Right Algorithm +---------------------------- + +Algorithms have different performance characteristics: + +.. list-table:: + :header-rows: 1 + + * - Algorithm + - Time Complexity + - Space Complexity + - Notes + * - Token Bucket + - O(1) + - O(1) + - Two floats per key + * - Fixed Window + - O(1) + - O(1) + - One int + one float per key + * - Sliding Window Counter + - O(1) + - O(1) + - Three values per key + * - Leaky Bucket + - O(1) + - O(1) + - Two floats per key + * - Sliding Window + - O(n) + - O(n) + - Stores every timestamp + +**Recommendation:** Use Sliding Window Counter (the default) unless you have +specific requirements. It's O(1) and provides good accuracy. + +**Avoid Sliding Window for high-volume endpoints.** If you're allowing 10,000 +requests per minute, that's 10,000 timestamps to store and filter per key. + +Memory Backend Optimization +--------------------------- + +The memory backend is already fast, but you can tune it: + +.. code-block:: python + + from fastapi_traffic import MemoryBackend + + backend = MemoryBackend( + max_size=10000, # Limit memory usage + cleanup_interval=60, # Less frequent cleanup = less overhead + ) + +**max_size:** Limits the number of keys stored. When exceeded, LRU eviction kicks +in. Set this based on your expected number of unique clients. + +**cleanup_interval:** How often to scan for expired entries. Higher values mean +less CPU overhead but more memory usage from expired entries. + +SQLite Backend Optimization +--------------------------- + +SQLite is surprisingly fast for rate limiting: + +.. code-block:: python + + from fastapi_traffic import SQLiteBackend + + backend = SQLiteBackend( + "rate_limits.db", + cleanup_interval=300, # Clean every 5 minutes + ) + +**Tips:** + +1. **Use an SSD.** SQLite performance depends heavily on disk I/O. + +2. **Put the database on a local disk.** Network-attached storage adds latency. + +3. **WAL mode is enabled by default.** This allows concurrent reads and writes. + +4. **Increase cleanup_interval** if you have many keys. Cleanup scans the entire + table. + +Redis Backend Optimization +-------------------------- + +Redis is the bottleneck in most distributed setups: + +**1. Use connection pooling (automatic):** + +The backend maintains a pool of connections. You don't need to do anything. + +**2. Use pipelining for batch operations:** + +If you're checking multiple rate limits, batch them: + +.. code-block:: python + + # Instead of multiple round trips + result1 = await limiter.check(request, config1) + result2 = await limiter.check(request, config2) + + # Consider combining into one check with higher cost + combined_config = RateLimitConfig(limit=100, window_size=60, cost=2) + result = await limiter.check(request, combined_config) + +**3. Use Redis close to your application:** + +Network latency is usually the biggest factor. Run Redis in the same datacenter, +or better yet, the same availability zone. + +**4. Consider Redis Cluster for high throughput:** + +Distributes load across multiple Redis nodes. + +Reducing Overhead +----------------- + +**1. Exempt paths that don't need limiting:** + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_paths={"/health", "/metrics", "/ready"}, + ) + +**2. Use coarse-grained limits when possible:** + +Instead of limiting every endpoint separately, use middleware for a global limit: + +.. code-block:: python + + # One check per request + app.add_middleware(RateLimitMiddleware, limit=1000, window_size=60) + + # vs. multiple checks per request + @rate_limit(100, 60) # Check 1 + @another_decorator # Check 2 + async def endpoint(): + pass + +**3. Increase window size:** + +Longer windows mean fewer state updates: + +.. code-block:: python + + # Updates state 60 times per minute per client + @rate_limit(60, 60) + + # Updates state 1 time per minute per client + @rate_limit(1, 1) # Same rate, but per-second + +Wait, that's backwards. Actually, the number of state updates equals the number +of requests, regardless of window size. But longer windows mean: + +- Fewer unique window boundaries +- Better cache efficiency +- More stable rate limiting + +**4. Skip headers when not needed:** + +.. code-block:: python + + @rate_limit(100, 60, include_headers=False) + +Saves a tiny bit of response processing. + +Benchmarking +------------ + +Here's a simple benchmark script: + +.. code-block:: python + + import asyncio + import time + from fastapi_traffic import MemoryBackend, RateLimiter, RateLimitConfig + from unittest.mock import MagicMock + + async def benchmark(): + backend = MemoryBackend() + limiter = RateLimiter(backend) + await limiter.initialize() + + config = RateLimitConfig(limit=10000, window_size=60) + + # Mock request + request = MagicMock() + request.client.host = "127.0.0.1" + request.url.path = "/test" + request.method = "GET" + request.headers = {} + + # Warm up + for _ in range(100): + await limiter.check(request, config) + + # Benchmark + iterations = 10000 + start = time.perf_counter() + + for _ in range(iterations): + await limiter.check(request, config) + + elapsed = time.perf_counter() - start + + print(f"Total time: {elapsed:.3f}s") + print(f"Per check: {elapsed/iterations*1000:.3f}ms") + print(f"Checks/sec: {iterations/elapsed:.0f}") + + await limiter.close() + + asyncio.run(benchmark()) + +Typical output: + +.. code-block:: text + + Total time: 0.150s + Per check: 0.015ms + Checks/sec: 66666 + +Profiling +--------- + +If you suspect rate limiting is a bottleneck, profile it: + +.. code-block:: python + + import cProfile + import pstats + + async def profile_rate_limiting(): + # Your rate limiting code here + pass + + cProfile.run('asyncio.run(profile_rate_limiting())', 'rate_limit.prof') + + stats = pstats.Stats('rate_limit.prof') + stats.sort_stats('cumulative') + stats.print_stats(20) + +Look for: + +- Time spent in backend operations +- Time spent in algorithm calculations +- Unexpected hotspots + +When Performance Really Matters +------------------------------- + +If you're handling millions of requests per second and rate limiting overhead +is significant: + +1. **Consider sampling:** Only check rate limits for a percentage of requests + and extrapolate. + +2. **Use probabilistic data structures:** Bloom filters or Count-Min Sketch can + approximate rate limiting with less overhead. + +3. **Push to the edge:** Use CDN-level rate limiting (Cloudflare, AWS WAF) to + handle the bulk of traffic. + +4. **Accept some inaccuracy:** Fixed window with ``skip_on_error=True`` is very + fast and "good enough" for many use cases. + +For most applications, though, the default configuration is plenty fast. diff --git a/docs/advanced/testing.rst b/docs/advanced/testing.rst new file mode 100644 index 0000000..5dc263b --- /dev/null +++ b/docs/advanced/testing.rst @@ -0,0 +1,367 @@ +Testing +======= + +Testing rate-limited endpoints requires some care. You don't want your tests to +be flaky because of timing issues, and you need to verify that limits actually work. + +Basic Testing Setup +------------------- + +Use pytest with pytest-asyncio for async tests: + +.. code-block:: python + + # conftest.py + import pytest + from fastapi.testclient import TestClient + from fastapi_traffic import MemoryBackend, RateLimiter + from fastapi_traffic.core.limiter import set_limiter + + @pytest.fixture + def app(): + """Create a fresh app for each test.""" + from myapp import create_app + return create_app() + + @pytest.fixture + def client(app): + """Test client with fresh rate limiter.""" + backend = MemoryBackend() + limiter = RateLimiter(backend) + set_limiter(limiter) + + with TestClient(app) as client: + yield client + +Testing Rate Limit Enforcement +------------------------------ + +Verify that the limit is actually enforced: + +.. code-block:: python + + def test_rate_limit_enforced(client): + """Test that requests are blocked after limit is reached.""" + # Make requests up to the limit + for i in range(10): + response = client.get("/api/data") + assert response.status_code == 200, f"Request {i+1} should succeed" + + # Next request should be rate limited + response = client.get("/api/data") + assert response.status_code == 429 + assert "retry_after" in response.json() + +Testing Rate Limit Headers +-------------------------- + +Check that headers are included correctly: + +.. code-block:: python + + def test_rate_limit_headers(client): + """Test that rate limit headers are present.""" + response = client.get("/api/data") + + assert "X-RateLimit-Limit" in response.headers + assert "X-RateLimit-Remaining" in response.headers + assert "X-RateLimit-Reset" in response.headers + + # Verify values make sense + limit = int(response.headers["X-RateLimit-Limit"]) + remaining = int(response.headers["X-RateLimit-Remaining"]) + + assert limit == 100 # Your configured limit + assert remaining == 99 # One request made + +Testing Different Clients +------------------------- + +Verify that different clients have separate limits: + +.. code-block:: python + + def test_separate_limits_per_client(client): + """Test that different IPs have separate limits.""" + # Client A makes requests + for _ in range(10): + response = client.get( + "/api/data", + headers={"X-Forwarded-For": "1.1.1.1"} + ) + assert response.status_code == 200 + + # Client A is now limited + response = client.get( + "/api/data", + headers={"X-Forwarded-For": "1.1.1.1"} + ) + assert response.status_code == 429 + + # Client B should still have full quota + response = client.get( + "/api/data", + headers={"X-Forwarded-For": "2.2.2.2"} + ) + assert response.status_code == 200 + +Testing Window Reset +-------------------- + +Test that limits reset after the window expires: + +.. code-block:: python + + import time + from unittest.mock import patch + + def test_limit_resets_after_window(client): + """Test that limits reset after window expires.""" + # Exhaust the limit + for _ in range(10): + client.get("/api/data") + + # Should be limited + response = client.get("/api/data") + assert response.status_code == 429 + + # Fast-forward time (mock time.time) + with patch('time.time') as mock_time: + # Move 61 seconds into the future + mock_time.return_value = time.time() + 61 + + # Should be allowed again + response = client.get("/api/data") + assert response.status_code == 200 + +Testing Exemptions +------------------ + +Verify that exemptions work: + +.. code-block:: python + + def test_exempt_paths(client): + """Test that exempt paths bypass rate limiting.""" + # Exhaust limit on a regular endpoint + for _ in range(100): + client.get("/api/data") + + # Regular endpoint should be limited + response = client.get("/api/data") + assert response.status_code == 429 + + # Health check should still work + response = client.get("/health") + assert response.status_code == 200 + + def test_exempt_ips(client): + """Test that exempt IPs bypass rate limiting.""" + # Make many requests from exempt IP + for _ in range(1000): + response = client.get( + "/api/data", + headers={"X-Forwarded-For": "127.0.0.1"} + ) + assert response.status_code == 200 # Never limited + +Testing with Async Client +------------------------- + +For async endpoints, use httpx: + +.. code-block:: python + + import pytest + import httpx + + @pytest.mark.asyncio + async def test_async_rate_limiting(): + """Test rate limiting with async client.""" + async with httpx.AsyncClient(app=app, base_url="http://test") as client: + # Make concurrent requests + responses = await asyncio.gather(*[ + client.get("/api/data") + for _ in range(15) + ]) + + successes = sum(1 for r in responses if r.status_code == 200) + limited = sum(1 for r in responses if r.status_code == 429) + + assert successes == 10 # Limit + assert limited == 5 # Over limit + +Testing Backend Failures +------------------------ + +Test behavior when the backend fails: + +.. code-block:: python + + from unittest.mock import AsyncMock, patch + from fastapi_traffic import BackendError + + def test_skip_on_error(client): + """Test that requests are allowed when backend fails and skip_on_error=True.""" + with patch.object( + MemoryBackend, 'get', + side_effect=BackendError("Connection failed") + ): + # With skip_on_error=True, should still work + response = client.get("/api/data") + assert response.status_code == 200 + + def test_fail_on_error(client): + """Test that requests fail when backend fails and skip_on_error=False.""" + with patch.object( + MemoryBackend, 'get', + side_effect=BackendError("Connection failed") + ): + # With skip_on_error=False (default), should fail + response = client.get("/api/strict-data") + assert response.status_code == 500 + +Mocking the Rate Limiter +------------------------ + +For unit tests, you might want to mock the rate limiter entirely: + +.. code-block:: python + + from unittest.mock import AsyncMock, MagicMock + from fastapi_traffic.core.limiter import set_limiter + from fastapi_traffic.core.models import RateLimitInfo, RateLimitResult + + def test_with_mocked_limiter(client): + """Test endpoint logic without actual rate limiting.""" + mock_limiter = MagicMock() + mock_limiter.hit = AsyncMock(return_value=RateLimitResult( + allowed=True, + info=RateLimitInfo( + limit=100, + remaining=99, + reset_at=time.time() + 60, + window_size=60, + ), + key="test", + )) + + set_limiter(mock_limiter) + + response = client.get("/api/data") + assert response.status_code == 200 + mock_limiter.hit.assert_called_once() + +Integration Testing with Redis +------------------------------ + +For integration tests with Redis: + +.. code-block:: python + + import pytest + from fastapi_traffic.backends.redis import RedisBackend + + @pytest.fixture + async def redis_backend(): + """Create a Redis backend for testing.""" + backend = await RedisBackend.from_url( + "redis://localhost:6379/15", # Use a test database + key_prefix="test:", + ) + yield backend + await backend.clear() # Clean up after test + await backend.close() + + @pytest.mark.asyncio + async def test_redis_rate_limiting(redis_backend): + """Test rate limiting with real Redis.""" + limiter = RateLimiter(redis_backend) + await limiter.initialize() + + config = RateLimitConfig(limit=5, window_size=60) + request = create_mock_request("1.1.1.1") + + # Make requests up to limit + for _ in range(5): + result = await limiter.check(request, config) + assert result.allowed + + # Next should be blocked + result = await limiter.check(request, config) + assert not result.allowed + + await limiter.close() + +Fixtures for Common Scenarios +----------------------------- + +.. code-block:: python + + # conftest.py + import pytest + from fastapi_traffic import MemoryBackend, RateLimiter, RateLimitConfig + from fastapi_traffic.core.limiter import set_limiter + + @pytest.fixture + def fresh_limiter(): + """Fresh rate limiter for each test.""" + backend = MemoryBackend() + limiter = RateLimiter(backend) + set_limiter(limiter) + return limiter + + @pytest.fixture + def rate_limit_config(): + """Standard rate limit config for tests.""" + return RateLimitConfig( + limit=10, + window_size=60, + ) + + @pytest.fixture + def mock_request(): + """Create a mock request.""" + def _create(ip="127.0.0.1", path="/test"): + request = MagicMock() + request.client.host = ip + request.url.path = path + request.method = "GET" + request.headers = {} + return request + return _create + +Avoiding Flaky Tests +-------------------- + +Rate limiting tests can be flaky due to timing. Tips: + +1. **Use short windows for tests:** + + .. code-block:: python + + @rate_limit(10, 1) # 10 per second, not 10 per minute + +2. **Mock time instead of sleeping:** + + .. code-block:: python + + with patch('time.time', return_value=future_time): + # Test window reset + +3. **Reset state between tests:** + + .. code-block:: python + + @pytest.fixture(autouse=True) + async def reset_limiter(): + yield + limiter = get_limiter() + await limiter.backend.clear() + +4. **Use unique keys per test:** + + .. code-block:: python + + def test_something(mock_request): + request = mock_request(ip=f"test-{uuid.uuid4()}") diff --git a/docs/api/algorithms.rst b/docs/api/algorithms.rst new file mode 100644 index 0000000..832968b --- /dev/null +++ b/docs/api/algorithms.rst @@ -0,0 +1,211 @@ +Algorithms API +============== + +Rate limiting algorithms and the factory function to create them. + +Algorithm Enum +-------------- + +.. py:class:: Algorithm + + Enumeration of available rate limiting algorithms. + + .. py:attribute:: TOKEN_BUCKET + :value: "token_bucket" + + Token bucket algorithm. Allows bursts up to bucket capacity, then refills + at a steady rate. + + .. py:attribute:: SLIDING_WINDOW + :value: "sliding_window" + + Sliding window log algorithm. Tracks exact timestamps for precise limiting. + Higher memory usage. + + .. py:attribute:: FIXED_WINDOW + :value: "fixed_window" + + Fixed window algorithm. Simple time-based windows. Efficient but has + boundary issues. + + .. py:attribute:: LEAKY_BUCKET + :value: "leaky_bucket" + + Leaky bucket algorithm. Smooths out request rate for consistent throughput. + + .. py:attribute:: SLIDING_WINDOW_COUNTER + :value: "sliding_window_counter" + + Sliding window counter algorithm. Balances precision and efficiency. + This is the default. + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import Algorithm, rate_limit + + @rate_limit(100, 60, algorithm=Algorithm.TOKEN_BUCKET) + async def endpoint(request: Request): + return {"status": "ok"} + +BaseAlgorithm +------------- + +.. py:class:: BaseAlgorithm(limit, window_size, backend, *, burst_size=None) + + Abstract base class for rate limiting algorithms. + + :param limit: Maximum requests allowed in the window. + :type limit: int + :param window_size: Time window in seconds. + :type window_size: float + :param backend: Storage backend for rate limit state. + :type backend: Backend + :param burst_size: Maximum burst size. Defaults to limit. + :type burst_size: int | None + + .. py:method:: check(key) + :async: + + Check if a request is allowed and update state. + + :param key: The rate limit key. + :type key: str + :returns: Tuple of (allowed, RateLimitInfo). + :rtype: tuple[bool, RateLimitInfo] + + .. py:method:: reset(key) + :async: + + Reset the rate limit state for a key. + + :param key: The rate limit key. + :type key: str + + .. py:method:: get_state(key) + :async: + + Get current state without consuming a token. + + :param key: The rate limit key. + :type key: str + :returns: Current rate limit info or None. + :rtype: RateLimitInfo | None + +TokenBucketAlgorithm +-------------------- + +.. py:class:: TokenBucketAlgorithm(limit, window_size, backend, *, burst_size=None) + + Token bucket algorithm implementation. + + Tokens are added to the bucket at a rate of ``limit / window_size`` per second. + Each request consumes one token. If no tokens are available, the request is + rejected. + + The ``burst_size`` parameter controls the maximum bucket capacity, allowing + short bursts of traffic. + + **State stored:** + + - ``tokens``: Current number of tokens in the bucket + - ``last_update``: Timestamp of last update + +SlidingWindowAlgorithm +---------------------- + +.. py:class:: SlidingWindowAlgorithm(limit, window_size, backend, *, burst_size=None) + + Sliding window log algorithm implementation. + + Stores the timestamp of every request within the window. Provides the most + accurate rate limiting but uses more memory. + + **State stored:** + + - ``timestamps``: List of request timestamps within the window + +FixedWindowAlgorithm +-------------------- + +.. py:class:: FixedWindowAlgorithm(limit, window_size, backend, *, burst_size=None) + + Fixed window algorithm implementation. + + Divides time into fixed windows and counts requests in each window. Simple + and efficient, but allows up to 2x the limit at window boundaries. + + **State stored:** + + - ``count``: Number of requests in current window + - ``window_start``: Start timestamp of current window + +LeakyBucketAlgorithm +-------------------- + +.. py:class:: LeakyBucketAlgorithm(limit, window_size, backend, *, burst_size=None) + + Leaky bucket algorithm implementation. + + Requests fill a bucket that "leaks" at a constant rate. Smooths out traffic + for consistent throughput. + + **State stored:** + + - ``water_level``: Current water level in the bucket + - ``last_update``: Timestamp of last update + +SlidingWindowCounterAlgorithm +----------------------------- + +.. py:class:: SlidingWindowCounterAlgorithm(limit, window_size, backend, *, burst_size=None) + + Sliding window counter algorithm implementation. + + Maintains counters for current and previous windows, calculating a weighted + average based on window progress. Balances precision and memory efficiency. + + **State stored:** + + - ``prev_count``: Count from previous window + - ``curr_count``: Count in current window + - ``current_window``: Start timestamp of current window + +get_algorithm +------------- + +.. py:function:: get_algorithm(algorithm, limit, window_size, backend, *, burst_size=None) + + Factory function to create algorithm instances. + + :param algorithm: The algorithm type to create. + :type algorithm: Algorithm + :param limit: Maximum requests allowed. + :type limit: int + :param window_size: Time window in seconds. + :type window_size: float + :param backend: Storage backend. + :type backend: Backend + :param burst_size: Maximum burst size. + :type burst_size: int | None + :returns: An algorithm instance. + :rtype: BaseAlgorithm + + **Usage:** + + .. code-block:: python + + from fastapi_traffic.core.algorithms import get_algorithm, Algorithm + from fastapi_traffic import MemoryBackend + + backend = MemoryBackend() + algorithm = get_algorithm( + Algorithm.TOKEN_BUCKET, + limit=100, + window_size=60, + backend=backend, + burst_size=20, + ) + + allowed, info = await algorithm.check("user:123") diff --git a/docs/api/backends.rst b/docs/api/backends.rst new file mode 100644 index 0000000..2d5b226 --- /dev/null +++ b/docs/api/backends.rst @@ -0,0 +1,266 @@ +Backends API +============ + +Storage backends for rate limit state. + +Backend (Base Class) +-------------------- + +.. py:class:: Backend + + Abstract base class for rate limit storage backends. + + All backends must implement these methods: + + .. py:method:: get(key) + :async: + + Get the current state for a key. + + :param key: The rate limit key. + :type key: str + :returns: The stored state dictionary or None if not found. + :rtype: dict[str, Any] | None + + .. py:method:: set(key, value, *, ttl) + :async: + + Set the state for a key with TTL. + + :param key: The rate limit key. + :type key: str + :param value: The state dictionary to store. + :type value: dict[str, Any] + :param ttl: Time-to-live in seconds. + :type ttl: float + + .. py:method:: delete(key) + :async: + + Delete the state for a key. + + :param key: The rate limit key. + :type key: str + + .. py:method:: exists(key) + :async: + + Check if a key exists. + + :param key: The rate limit key. + :type key: str + :returns: True if the key exists. + :rtype: bool + + .. py:method:: increment(key, amount=1) + :async: + + Atomically increment a counter. + + :param key: The rate limit key. + :type key: str + :param amount: The amount to increment by. + :type amount: int + :returns: The new value after incrementing. + :rtype: int + + .. py:method:: clear() + :async: + + Clear all rate limit data. + + .. py:method:: close() + :async: + + Close the backend connection. + + Backends support async context manager protocol: + + .. code-block:: python + + async with MemoryBackend() as backend: + await backend.set("key", {"count": 1}, ttl=60) + +MemoryBackend +------------- + +.. py:class:: MemoryBackend(max_size=10000, cleanup_interval=60) + + In-memory storage backend with LRU eviction and TTL cleanup. + + :param max_size: Maximum number of keys to store. + :type max_size: int + :param cleanup_interval: How often to clean expired entries (seconds). + :type cleanup_interval: float + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import MemoryBackend, RateLimiter + + backend = MemoryBackend(max_size=10000) + limiter = RateLimiter(backend) + + .. py:method:: get_stats() + + Get statistics about the backend. + + :returns: Dictionary with stats like key count, memory usage. + :rtype: dict[str, Any] + + .. py:method:: start_cleanup() + :async: + + Start the background cleanup task. + + .. py:method:: stop_cleanup() + :async: + + Stop the background cleanup task. + +SQLiteBackend +------------- + +.. py:class:: SQLiteBackend(db_path, cleanup_interval=300) + + SQLite storage backend for persistent rate limiting. + + :param db_path: Path to the SQLite database file. + :type db_path: str | Path + :param cleanup_interval: How often to clean expired entries (seconds). + :type cleanup_interval: float + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import SQLiteBackend, RateLimiter + + backend = SQLiteBackend("rate_limits.db") + limiter = RateLimiter(backend) + + @app.on_event("startup") + async def startup(): + await limiter.initialize() + + @app.on_event("shutdown") + async def shutdown(): + await limiter.close() + + .. py:method:: initialize() + :async: + + Initialize the database schema. + + Features: + + - WAL mode for better concurrent performance + - Automatic schema creation + - Connection pooling + - Background cleanup of expired entries + +RedisBackend +------------ + +.. py:class:: RedisBackend + + Redis storage backend for distributed rate limiting. + + .. py:method:: from_url(url, *, key_prefix="", **kwargs) + :classmethod: + + Create a RedisBackend from a Redis URL. This is an async classmethod. + + :param url: Redis connection URL. + :type url: str + :param key_prefix: Prefix for all keys. + :type key_prefix: str + :returns: Configured RedisBackend instance. + :rtype: RedisBackend + + **Usage:** + + .. code-block:: python + + from fastapi_traffic.backends.redis import RedisBackend + from fastapi_traffic import RateLimiter + + @app.on_event("startup") + async def startup(): + backend = await RedisBackend.from_url("redis://localhost:6379/0") + limiter = RateLimiter(backend) + + **Connection examples:** + + .. code-block:: python + + # Simple connection + backend = await RedisBackend.from_url("redis://localhost:6379/0") + + # With password + backend = await RedisBackend.from_url("redis://:password@localhost:6379/0") + + # With key prefix + backend = await RedisBackend.from_url( + "redis://localhost:6379/0", + key_prefix="myapp:ratelimit:", + ) + + .. py:method:: get_stats() + :async: + + Get statistics about the Redis backend. + + :returns: Dictionary with stats like key count, memory usage. + :rtype: dict[str, Any] + + Features: + + - Atomic operations via Lua scripts + - Automatic key expiration + - Connection pooling + - Support for Redis Sentinel and Cluster + +Implementing Custom Backends +---------------------------- + +To create a custom backend, inherit from ``Backend`` and implement all abstract +methods: + +.. code-block:: python + + from fastapi_traffic.backends.base import Backend + from typing import Any + + class MyBackend(Backend): + async def get(self, key: str) -> dict[str, Any] | None: + # Retrieve state from your storage + pass + + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + # Store state with expiration + pass + + async def delete(self, key: str) -> None: + # Remove a key + pass + + async def exists(self, key: str) -> bool: + # Check if key exists + pass + + async def increment(self, key: str, amount: int = 1) -> int: + # Atomically increment (important for accuracy) + pass + + async def clear(self) -> None: + # Clear all data + pass + + async def close(self) -> None: + # Clean up connections + pass + +The ``value`` dictionary contains algorithm-specific state. Your backend should +serialize it appropriately (JSON works well for most cases). diff --git a/docs/api/config.rst b/docs/api/config.rst new file mode 100644 index 0000000..213c2a4 --- /dev/null +++ b/docs/api/config.rst @@ -0,0 +1,245 @@ +Configuration API +================= + +Configuration classes and loaders for rate limiting. + +RateLimitConfig +--------------- + +.. py:class:: RateLimitConfig(limit, window_size=60.0, algorithm=Algorithm.SLIDING_WINDOW_COUNTER, key_prefix="ratelimit", key_extractor=default_key_extractor, burst_size=None, include_headers=True, error_message="Rate limit exceeded", status_code=429, skip_on_error=False, cost=1, exempt_when=None, on_blocked=None) + + Configuration for a rate limit rule. + + :param limit: Maximum requests allowed in the window. Must be positive. + :type limit: int + :param window_size: Time window in seconds. Must be positive. + :type window_size: float + :param algorithm: Rate limiting algorithm to use. + :type algorithm: Algorithm + :param key_prefix: Prefix for the rate limit key. + :type key_prefix: str + :param key_extractor: Function to extract client identifier from request. + :type key_extractor: Callable[[Request], str] + :param burst_size: Maximum burst size for token/leaky bucket. + :type burst_size: int | None + :param include_headers: Whether to include rate limit headers. + :type include_headers: bool + :param error_message: Error message when rate limited. + :type error_message: str + :param status_code: HTTP status code when rate limited. + :type status_code: int + :param skip_on_error: Skip rate limiting on backend errors. + :type skip_on_error: bool + :param cost: Cost per request. + :type cost: int + :param exempt_when: Function to check if request is exempt. + :type exempt_when: Callable[[Request], bool] | None + :param on_blocked: Callback when request is blocked. + :type on_blocked: Callable[[Request, Any], Any] | None + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import RateLimitConfig, Algorithm + + config = RateLimitConfig( + limit=100, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=20, + ) + +GlobalConfig +------------ + +.. py:class:: GlobalConfig(backend=None, enabled=True, default_limit=100, default_window_size=60.0, default_algorithm=Algorithm.SLIDING_WINDOW_COUNTER, key_prefix="fastapi_traffic", include_headers=True, error_message="Rate limit exceeded. Please try again later.", status_code=429, skip_on_error=False, exempt_ips=set(), exempt_paths=set(), headers_prefix="X-RateLimit") + + Global configuration for the rate limiter. + + :param backend: Storage backend for rate limit data. + :type backend: Backend | None + :param enabled: Whether rate limiting is enabled. + :type enabled: bool + :param default_limit: Default maximum requests per window. + :type default_limit: int + :param default_window_size: Default time window in seconds. + :type default_window_size: float + :param default_algorithm: Default rate limiting algorithm. + :type default_algorithm: Algorithm + :param key_prefix: Global prefix for all rate limit keys. + :type key_prefix: str + :param include_headers: Include rate limit headers by default. + :type include_headers: bool + :param error_message: Default error message. + :type error_message: str + :param status_code: Default HTTP status code. + :type status_code: int + :param skip_on_error: Skip rate limiting on backend errors. + :type skip_on_error: bool + :param exempt_ips: IP addresses exempt from rate limiting. + :type exempt_ips: set[str] + :param exempt_paths: URL paths exempt from rate limiting. + :type exempt_paths: set[str] + :param headers_prefix: Prefix for rate limit headers. + :type headers_prefix: str + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import GlobalConfig, RateLimiter + + config = GlobalConfig( + enabled=True, + default_limit=100, + exempt_paths={"/health", "/docs"}, + exempt_ips={"127.0.0.1"}, + ) + + limiter = RateLimiter(config=config) + +ConfigLoader +------------ + +.. py:class:: ConfigLoader(prefix="FASTAPI_TRAFFIC") + + Load rate limit configuration from various sources. + + :param prefix: Environment variable prefix. + :type prefix: str + + .. py:method:: load_rate_limit_config_from_env(env_vars=None, **overrides) + + Load RateLimitConfig from environment variables. + + :param env_vars: Dictionary of environment variables. Uses os.environ if None. + :type env_vars: dict[str, str] | None + :param overrides: Values to override after loading. + :returns: Loaded configuration. + :rtype: RateLimitConfig + + .. py:method:: load_rate_limit_config_from_json(file_path, **overrides) + + Load RateLimitConfig from a JSON file. + + :param file_path: Path to the JSON file. + :type file_path: str | Path + :param overrides: Values to override after loading. + :returns: Loaded configuration. + :rtype: RateLimitConfig + + .. py:method:: load_rate_limit_config_from_env_file(file_path, **overrides) + + Load RateLimitConfig from a .env file. + + :param file_path: Path to the .env file. + :type file_path: str | Path + :param overrides: Values to override after loading. + :returns: Loaded configuration. + :rtype: RateLimitConfig + + .. py:method:: load_global_config_from_env(env_vars=None, **overrides) + + Load GlobalConfig from environment variables. + + .. py:method:: load_global_config_from_json(file_path, **overrides) + + Load GlobalConfig from a JSON file. + + .. py:method:: load_global_config_from_env_file(file_path, **overrides) + + Load GlobalConfig from a .env file. + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import ConfigLoader + + loader = ConfigLoader() + + # From environment + config = loader.load_rate_limit_config_from_env() + + # From JSON file + config = loader.load_rate_limit_config_from_json("config.json") + + # From .env file + config = loader.load_rate_limit_config_from_env_file(".env") + + # With overrides + config = loader.load_rate_limit_config_from_json( + "config.json", + limit=200, # Override the limit + ) + +Convenience Functions +--------------------- + +.. py:function:: load_rate_limit_config(file_path, **overrides) + + Load RateLimitConfig with automatic format detection. + + :param file_path: Path to config file (.json or .env). + :type file_path: str | Path + :returns: Loaded configuration. + :rtype: RateLimitConfig + +.. py:function:: load_rate_limit_config_from_env(**overrides) + + Load RateLimitConfig from environment variables. + + :returns: Loaded configuration. + :rtype: RateLimitConfig + +.. py:function:: load_global_config(file_path, **overrides) + + Load GlobalConfig with automatic format detection. + + :param file_path: Path to config file (.json or .env). + :type file_path: str | Path + :returns: Loaded configuration. + :rtype: GlobalConfig + +.. py:function:: load_global_config_from_env(**overrides) + + Load GlobalConfig from environment variables. + + :returns: Loaded configuration. + :rtype: GlobalConfig + +**Usage:** + +.. code-block:: python + + from fastapi_traffic import ( + load_rate_limit_config, + load_rate_limit_config_from_env, + ) + + # Auto-detect format + config = load_rate_limit_config("config.json") + config = load_rate_limit_config(".env") + + # From environment + config = load_rate_limit_config_from_env() + +default_key_extractor +--------------------- + +.. py:function:: default_key_extractor(request) + + Extract client IP as the default rate limit key. + + Checks in order: + + 1. ``X-Forwarded-For`` header (first IP) + 2. ``X-Real-IP`` header + 3. Direct connection IP + 4. Falls back to "unknown" + + :param request: The incoming request. + :type request: Request + :returns: Client identifier string. + :rtype: str diff --git a/docs/api/decorator.rst b/docs/api/decorator.rst new file mode 100644 index 0000000..32aa055 --- /dev/null +++ b/docs/api/decorator.rst @@ -0,0 +1,154 @@ +Decorator API +============= + +The ``@rate_limit`` decorator is the primary way to add rate limiting to your +FastAPI endpoints. + +rate_limit +---------- + +.. py:function:: rate_limit(limit, window_size=60.0, *, algorithm=Algorithm.SLIDING_WINDOW_COUNTER, key_prefix="ratelimit", key_extractor=default_key_extractor, burst_size=None, include_headers=True, error_message="Rate limit exceeded", status_code=429, skip_on_error=False, cost=1, exempt_when=None, on_blocked=None) + + Apply rate limiting to a FastAPI endpoint. + + :param limit: Maximum number of requests allowed in the window. + :type limit: int + :param window_size: Time window in seconds. Defaults to 60. + :type window_size: float + :param algorithm: Rate limiting algorithm to use. + :type algorithm: Algorithm + :param key_prefix: Prefix for the rate limit key. + :type key_prefix: str + :param key_extractor: Function to extract client identifier from request. + :type key_extractor: Callable[[Request], str] + :param burst_size: Maximum burst size for token bucket/leaky bucket algorithms. + :type burst_size: int | None + :param include_headers: Whether to include rate limit headers in response. + :type include_headers: bool + :param error_message: Error message when rate limit is exceeded. + :type error_message: str + :param status_code: HTTP status code when rate limit is exceeded. + :type status_code: int + :param skip_on_error: Skip rate limiting if backend errors occur. + :type skip_on_error: bool + :param cost: Cost of each request (default 1). + :type cost: int + :param exempt_when: Function to determine if request should be exempt. + :type exempt_when: Callable[[Request], bool] | None + :param on_blocked: Callback when a request is blocked. + :type on_blocked: Callable[[Request, Any], Any] | None + :returns: Decorated function with rate limiting applied. + :rtype: Callable + + **Basic usage:** + + .. code-block:: python + + from fastapi import FastAPI, Request + from fastapi_traffic import rate_limit + + app = FastAPI() + + @app.get("/api/data") + @rate_limit(100, 60) # 100 requests per minute + async def get_data(request: Request): + return {"data": "here"} + + **With algorithm:** + + .. code-block:: python + + from fastapi_traffic import rate_limit, Algorithm + + @app.get("/api/burst") + @rate_limit(100, 60, algorithm=Algorithm.TOKEN_BUCKET, burst_size=20) + async def burst_endpoint(request: Request): + return {"status": "ok"} + + **With custom key extractor:** + + .. code-block:: python + + def get_api_key(request: Request) -> str: + return request.headers.get("X-API-Key", "anonymous") + + @app.get("/api/data") + @rate_limit(1000, 3600, key_extractor=get_api_key) + async def api_endpoint(request: Request): + return {"data": "here"} + + **With exemption:** + + .. code-block:: python + + def is_admin(request: Request) -> bool: + return getattr(request.state, "is_admin", False) + + @app.get("/api/admin") + @rate_limit(100, 60, exempt_when=is_admin) + async def admin_endpoint(request: Request): + return {"admin": "data"} + +RateLimitDependency +------------------- + +.. py:class:: RateLimitDependency(limit, window_size=60.0, *, algorithm=Algorithm.SLIDING_WINDOW_COUNTER, key_prefix="ratelimit", key_extractor=default_key_extractor, burst_size=None, error_message="Rate limit exceeded", status_code=429, skip_on_error=False, cost=1, exempt_when=None) + :no-index: + + FastAPI dependency for rate limiting. Returns rate limit info that can be + used in your endpoint. See :doc:`dependency` for full documentation. + + :param limit: Maximum number of requests allowed in the window. + :type limit: int + :param window_size: Time window in seconds. + :type window_size: float + + **Usage:** + + .. code-block:: python + + from fastapi import FastAPI, Depends, Request + from fastapi_traffic.core.decorator import RateLimitDependency + + app = FastAPI() + rate_dep = RateLimitDependency(limit=100, window_size=60) + + @app.get("/api/data") + async def get_data(request: Request, rate_info=Depends(rate_dep)): + return { + "data": "here", + "remaining_requests": rate_info.remaining, + "reset_at": rate_info.reset_at, + } + + The dependency returns a ``RateLimitInfo`` object with: + + - ``limit``: The configured limit + - ``remaining``: Remaining requests in the current window + - ``reset_at``: Unix timestamp when the window resets + - ``retry_after``: Seconds until retry (if rate limited) + +create_rate_limit_response +-------------------------- + +.. py:function:: create_rate_limit_response(exc, *, include_headers=True) + + Create a standard rate limit response from a RateLimitExceeded exception. + + :param exc: The RateLimitExceeded exception. + :type exc: RateLimitExceeded + :param include_headers: Whether to include rate limit headers. + :type include_headers: bool + :returns: A JSONResponse with rate limit information. + :rtype: Response + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import RateLimitExceeded + from fastapi_traffic.core.decorator import create_rate_limit_response + + @app.exception_handler(RateLimitExceeded) + async def handler(request: Request, exc: RateLimitExceeded): + return create_rate_limit_response(exc) diff --git a/docs/api/dependency.rst b/docs/api/dependency.rst new file mode 100644 index 0000000..26a6ac1 --- /dev/null +++ b/docs/api/dependency.rst @@ -0,0 +1,473 @@ +Dependency Injection API +======================== + +If you're already using FastAPI's dependency injection system, you'll feel right +at home with ``RateLimitDependency``. It plugs directly into ``Depends``, giving +you rate limiting that works just like any other dependency—plus you get access +to rate limit info right inside your endpoint. + +RateLimitDependency +------------------- + +.. py:class:: RateLimitDependency(limit, window_size=60.0, *, algorithm=Algorithm.SLIDING_WINDOW_COUNTER, key_prefix="ratelimit", key_extractor=default_key_extractor, burst_size=None, error_message="Rate limit exceeded", status_code=429, skip_on_error=False, cost=1, exempt_when=None) + + This is the main class you'll use for dependency-based rate limiting. Create + an instance, pass it to ``Depends()``, and you're done. + + :param limit: Maximum number of requests allowed in the window. + :type limit: int + :param window_size: Time window in seconds. Defaults to 60. + :type window_size: float + :param algorithm: Rate limiting algorithm to use. + :type algorithm: Algorithm + :param key_prefix: Prefix for the rate limit key. + :type key_prefix: str + :param key_extractor: Function to extract client identifier from request. + :type key_extractor: Callable[[Request], str] + :param burst_size: Maximum burst size for token bucket/leaky bucket algorithms. + :type burst_size: int | None + :param error_message: Error message when rate limit is exceeded. + :type error_message: str + :param status_code: HTTP status code when rate limit is exceeded. + :type status_code: int + :param skip_on_error: Skip rate limiting if backend errors occur. + :type skip_on_error: bool + :param cost: Cost of each request (default 1). + :type cost: int + :param exempt_when: Function to determine if request should be exempt. + :type exempt_when: Callable[[Request], bool] | None + + **Returns:** A ``RateLimitInfo`` object with details about the current rate limit state. + +RateLimitInfo +------------- + +When the dependency runs, it hands you back a ``RateLimitInfo`` object. Here's +what's inside: + +.. py:class:: RateLimitInfo + + :param limit: The configured request limit. + :type limit: int + :param remaining: Remaining requests in the current window. + :type remaining: int + :param reset_at: Unix timestamp when the window resets. + :type reset_at: float + :param retry_after: Seconds until retry is allowed (if rate limited). + :type retry_after: float | None + :param window_size: The configured window size in seconds. + :type window_size: float + + .. py:method:: to_headers() -> dict[str, str] + + Converts the rate limit info into standard HTTP headers. Handy if you want + to add these headers to your response manually. + + :returns: A dictionary with ``X-RateLimit-Limit``, ``X-RateLimit-Remaining``, + ``X-RateLimit-Reset``, and ``Retry-After`` (when applicable). + +Setup +----- + +Before you can use the dependency, you need to set up the rate limiter. The +cleanest way is with FastAPI's lifespan context manager: + +.. code-block:: python + + from contextlib import asynccontextmanager + from fastapi import FastAPI + from fastapi_traffic import MemoryBackend, RateLimiter + 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(lifespan=lifespan) + +Basic Usage +----------- + +Here's the simplest way to get started. Create a dependency instance and inject +it with ``Depends``: + +.. code-block:: python + + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + + app = FastAPI() + + # Create the rate limit dependency + rate_limit_dep = RateLimitDependency(limit=100, window_size=60) + + @app.get("/api/data") + async def get_data( + request: Request, + rate_info=Depends(rate_limit_dep), + ): + return { + "data": "here", + "remaining_requests": rate_info.remaining, + "reset_at": rate_info.reset_at, + } + +Using Type Aliases +------------------ + +If you're using the same rate limit across multiple endpoints, type aliases +with ``Annotated`` make your code much cleaner: + +.. code-block:: python + + from typing import Annotated, TypeAlias + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + from fastapi_traffic.core.models import RateLimitInfo + + app = FastAPI() + + rate_limit_dep = RateLimitDependency(limit=100, window_size=60) + + # Create a type alias for cleaner signatures + RateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(rate_limit_dep)] + + @app.get("/api/data") + async def get_data(request: Request, rate_info: RateLimit): + return { + "data": "here", + "remaining": rate_info.remaining, + } + +Tiered Rate Limits +------------------ + +This is where dependency injection really shines. You can apply different rate +limits based on who's making the request—free users get 10 requests per minute, +pro users get 100, and enterprise gets 1000: + +.. code-block:: python + + from typing import Annotated, TypeAlias + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + from fastapi_traffic.core.models import RateLimitInfo + + app = FastAPI() + + # Define tier-specific limits + 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", + ) + + 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") + + TierDep: TypeAlias = Annotated[str, Depends(get_user_tier)] + + async def tiered_rate_limit( + request: Request, + tier: TierDep, + ) -> RateLimitInfo: + """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) + + TieredRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(tiered_rate_limit)] + + @app.get("/api/resource") + async def get_resource(request: Request, rate_info: TieredRateLimit): + tier = get_user_tier(request) + return { + "tier": tier, + "remaining": rate_info.remaining, + "limit": rate_info.limit, + } + +Custom Key Extraction +--------------------- + +By default, rate limits are tracked by IP address. But what if you want to rate +limit by API key instead? Just pass a custom ``key_extractor``: + +.. code-block:: python + + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + + app = FastAPI() + + 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, # 100 requests per hour + key_extractor=api_key_extractor, + ) + + @app.get("/api/resource") + async def api_resource( + request: Request, + rate_info=Depends(api_rate_limit), + ): + return { + "data": "Resource data", + "requests_remaining": rate_info.remaining, + } + +Multiple Rate Limits +-------------------- + +Sometimes you need layered protection—say, 10 requests per minute *and* 100 +requests per hour. Dependencies make this easy to compose: + +.. code-block:: python + + from typing import Annotated, Any, TypeAlias + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + from fastapi_traffic.core.models import RateLimitInfo + + app = FastAPI() + + per_minute_limit = RateLimitDependency( + limit=10, + window_size=60, + key_prefix="minute", + ) + + per_hour_limit = RateLimitDependency( + limit=100, + window_size=3600, + key_prefix="hour", + ) + + PerMinuteLimit: TypeAlias = Annotated[RateLimitInfo, Depends(per_minute_limit)] + PerHourLimit: TypeAlias = Annotated[RateLimitInfo, Depends(per_hour_limit)] + + async def combined_rate_limit( + request: Request, + minute_info: PerMinuteLimit, + hour_info: PerHourLimit, + ) -> 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, + }, + } + + CombinedRateLimit: TypeAlias = Annotated[dict[str, Any], Depends(combined_rate_limit)] + + @app.get("/api/combined") + async def combined_endpoint( + request: Request, + rate_info: CombinedRateLimit, + ): + return { + "message": "Success", + "rate_limits": rate_info, + } + +Exemption Logic +--------------- + +Need to let certain requests bypass rate limiting entirely? Maybe internal +services or admin users? Use the ``exempt_when`` parameter: + +.. code-block:: python + + from fastapi import Depends, FastAPI, Request + from fastapi_traffic.core.decorator import RateLimitDependency + + app = FastAPI() + + 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("/api/internal") + async def internal_endpoint( + request: Request, + rate_info=Depends(internal_exempt_limit), + ): + is_internal = is_internal_request(request) + return { + "message": "Success", + "is_internal": is_internal, + "rate_limit": None if is_internal else { + "remaining": rate_info.remaining, + }, + } + +Exception Handling +------------------ + +When a request exceeds the rate limit, a ``RateLimitExceeded`` exception is +raised. You'll want to catch this and return a proper response: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + from fastapi_traffic import RateLimitExceeded + + app = FastAPI() + + @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, + }, + ) + +Or if you prefer, there's a built-in helper that does the work for you: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi_traffic import RateLimitExceeded + from fastapi_traffic.core.decorator import create_rate_limit_response + + app = FastAPI() + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return create_rate_limit_response(exc, include_headers=True) + +Complete Example +---------------- + +Here's everything put together in a working example you can copy and run: + +.. code-block:: python + + from contextlib import asynccontextmanager + from typing import Annotated, TypeAlias + + from fastapi import Depends, FastAPI, Request + from fastapi.responses import JSONResponse + + from fastapi_traffic import ( + MemoryBackend, + RateLimiter, + RateLimitExceeded, + ) + from fastapi_traffic.core.decorator import RateLimitDependency + from fastapi_traffic.core.limiter import set_limiter + from fastapi_traffic.core.models import RateLimitInfo + + # Initialize backend and limiter + backend = MemoryBackend() + limiter = RateLimiter(backend) + + @asynccontextmanager + async def lifespan(app: FastAPI): + await limiter.initialize() + set_limiter(limiter) + yield + await limiter.close() + + app = FastAPI(lifespan=lifespan) + + # Exception handler + @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, + }, + ) + + # Create dependency + api_rate_limit = RateLimitDependency(limit=100, window_size=60) + ApiRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(api_rate_limit)] + + @app.get("/api/data") + async def get_data(request: Request, rate_info: ApiRateLimit): + return { + "data": "Your data here", + "rate_limit": { + "limit": rate_info.limit, + "remaining": rate_info.remaining, + "reset_at": rate_info.reset_at, + }, + } + +Decorator vs Dependency +----------------------- + +Not sure which approach to use? Here's a quick guide: + +**Go with the ``@rate_limit`` decorator if:** + +- You just want to slap a rate limit on an endpoint and move on +- You don't care about the remaining request count inside your endpoint +- You're applying the same limit to a bunch of endpoints + +**Go with ``RateLimitDependency`` if:** + +- You want to show users how many requests they have left +- You need different limits for different user tiers +- You're stacking multiple rate limits (per-minute + per-hour) +- You're already using FastAPI's dependency system and want consistency + +See Also +-------- + +- :doc:`decorator` - Decorator-based rate limiting +- :doc:`middleware` - Global middleware rate limiting +- :doc:`config` - Configuration options +- :doc:`exceptions` - Exception handling diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst new file mode 100644 index 0000000..2fc89e7 --- /dev/null +++ b/docs/api/exceptions.rst @@ -0,0 +1,165 @@ +Exceptions API +============== + +Custom exceptions raised by FastAPI Traffic. + +FastAPITrafficError +------------------- + +.. py:exception:: FastAPITrafficError + + Base exception for all FastAPI Traffic errors. + + All other exceptions in this library inherit from this class, so you can + catch all FastAPI Traffic errors with a single handler: + + .. code-block:: python + + from fastapi_traffic.exceptions import FastAPITrafficError + + @app.exception_handler(FastAPITrafficError) + async def handle_traffic_error(request: Request, exc: FastAPITrafficError): + return JSONResponse( + status_code=500, + content={"error": str(exc)}, + ) + +RateLimitExceeded +----------------- + +.. py:exception:: RateLimitExceeded(message="Rate limit exceeded", *, retry_after=None, limit_info=None) + + Raised when a rate limit has been exceeded. + + :param message: Error message. + :type message: str + :param retry_after: Seconds until the client can retry. + :type retry_after: float | None + :param limit_info: Detailed rate limit information. + :type limit_info: RateLimitInfo | None + + .. py:attribute:: message + :type: str + + The error message. + + .. py:attribute:: retry_after + :type: float | None + + Seconds until the client can retry. May be None if not calculable. + + .. py:attribute:: limit_info + :type: RateLimitInfo | None + + Detailed information about the rate limit state. + + **Usage:** + + .. code-block:: python + + from fastapi import Request + from fastapi.responses import JSONResponse + from fastapi_traffic import RateLimitExceeded + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + headers = {} + if exc.limit_info: + headers = exc.limit_info.to_headers() + + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": exc.message, + "retry_after": exc.retry_after, + }, + headers=headers, + ) + +BackendError +------------ + +.. py:exception:: BackendError(message="Backend operation failed", *, original_error=None) + + Raised when a backend operation fails. + + :param message: Error message. + :type message: str + :param original_error: The original exception that caused this error. + :type original_error: Exception | None + + .. py:attribute:: message + :type: str + + The error message. + + .. py:attribute:: original_error + :type: Exception | None + + The underlying exception, if any. + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import BackendError + + @app.exception_handler(BackendError) + async def backend_error_handler(request: Request, exc: BackendError): + # Log the original error for debugging + if exc.original_error: + logger.error("Backend error: %s", exc.original_error) + + return JSONResponse( + status_code=503, + content={"error": "service_unavailable"}, + ) + + This exception is raised when: + + - Redis connection fails + - SQLite database is locked or corrupted + - Any other backend storage operation fails + +ConfigurationError +------------------ + +.. py:exception:: ConfigurationError + + Raised when there is a configuration error. + + This exception is raised when: + + - Invalid values in configuration files + - Missing required configuration + - Type conversion failures + - Unknown configuration fields + + **Usage:** + + .. code-block:: python + + from fastapi_traffic import ConfigLoader, ConfigurationError + + loader = ConfigLoader() + + try: + config = loader.load_rate_limit_config_from_json("config.json") + except ConfigurationError as e: + print(f"Configuration error: {e}") + # Use default configuration + config = RateLimitConfig(limit=100, window_size=60) + +Exception Hierarchy +------------------- + +.. code-block:: text + + FastAPITrafficError + ├── RateLimitExceeded + ├── BackendError + └── ConfigurationError + +All exceptions inherit from ``FastAPITrafficError``, which inherits from +Python's built-in ``Exception``. diff --git a/docs/api/middleware.rst b/docs/api/middleware.rst new file mode 100644 index 0000000..57d128f --- /dev/null +++ b/docs/api/middleware.rst @@ -0,0 +1,118 @@ +Middleware API +============== + +Middleware for applying rate limiting globally across your application. + +RateLimitMiddleware +------------------- + +.. py:class:: RateLimitMiddleware(app, *, limit=100, window_size=60.0, algorithm=Algorithm.SLIDING_WINDOW_COUNTER, backend=None, key_prefix="middleware", include_headers=True, error_message="Rate limit exceeded. Please try again later.", status_code=429, skip_on_error=False, exempt_paths=None, exempt_ips=None, key_extractor=default_key_extractor) + + Middleware for global rate limiting across all endpoints. + + :param app: The ASGI application. + :type app: ASGIApp + :param limit: Maximum requests per window. + :type limit: int + :param window_size: Time window in seconds. + :type window_size: float + :param algorithm: Rate limiting algorithm. + :type algorithm: Algorithm + :param backend: Storage backend. Defaults to MemoryBackend. + :type backend: Backend | None + :param key_prefix: Prefix for rate limit keys. + :type key_prefix: str + :param include_headers: Include rate limit headers in response. + :type include_headers: bool + :param error_message: Error message when rate limited. + :type error_message: str + :param status_code: HTTP status code when rate limited. + :type status_code: int + :param skip_on_error: Skip rate limiting on backend errors. + :type skip_on_error: bool + :param exempt_paths: Paths to exempt from rate limiting. + :type exempt_paths: set[str] | None + :param exempt_ips: IP addresses to exempt from rate limiting. + :type exempt_ips: set[str] | None + :param key_extractor: Function to extract client identifier. + :type key_extractor: Callable[[Request], str] + + **Basic usage:** + + .. code-block:: python + + from fastapi import FastAPI + from fastapi_traffic.middleware import RateLimitMiddleware + + app = FastAPI() + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + ) + + **With exemptions:** + + .. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_paths={"/health", "/docs"}, + exempt_ips={"127.0.0.1"}, + ) + + **With custom backend:** + + .. code-block:: python + + from fastapi_traffic import SQLiteBackend + + backend = SQLiteBackend("rate_limits.db") + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + backend=backend, + ) + +SlidingWindowMiddleware +----------------------- + +.. py:class:: SlidingWindowMiddleware(app, *, limit=100, window_size=60.0, **kwargs) + + Convenience middleware using the sliding window algorithm. + + Accepts all the same parameters as ``RateLimitMiddleware``. + + .. code-block:: python + + from fastapi_traffic.middleware import SlidingWindowMiddleware + + app.add_middleware( + SlidingWindowMiddleware, + limit=1000, + window_size=60, + ) + +TokenBucketMiddleware +--------------------- + +.. py:class:: TokenBucketMiddleware(app, *, limit=100, window_size=60.0, **kwargs) + + Convenience middleware using the token bucket algorithm. + + Accepts all the same parameters as ``RateLimitMiddleware``. + + .. code-block:: python + + from fastapi_traffic.middleware import TokenBucketMiddleware + + app.add_middleware( + TokenBucketMiddleware, + limit=1000, + window_size=60, + ) diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 0000000..402d156 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,91 @@ +Changelog +========= + +All notable changes to FastAPI Traffic are documented here. + +The format is based on `Keep a Changelog `_, +and this project adheres to `Semantic Versioning `_. + +[0.2.1] - 2026-03-07 +-------------------- + +Changed +^^^^^^^ + +- Improved config loader validation using Pydantic schemas +- Added pydantic>=2.0 as a core dependency +- Fixed sync wrapper in decorator to properly handle rate limiting +- Updated pyright settings for stricter type checking +- Fixed repository URL in pyproject.toml + +Removed +^^^^^^^ + +- Removed unused main.py + +[0.2.0] - 2026-02-04 +-------------------- + +Added +^^^^^ + +- **Configuration Loader** — Load rate limiting configuration from external files: + + - ``ConfigLoader`` class for loading ``RateLimitConfig`` and ``GlobalConfig`` + - Support for ``.env`` files with ``FASTAPI_TRAFFIC_*`` prefixed variables + - Support for JSON configuration files + - Environment variable loading with ``load_rate_limit_config_from_env()`` and ``load_global_config_from_env()`` + - Auto-detection of file format with ``load_rate_limit_config()`` and ``load_global_config()`` + - Custom environment variable prefix support + - Type validation and comprehensive error handling + - 47 new tests for configuration loading + +- Example ``11_config_loader.py`` demonstrating all configuration loading patterns +- ``get_stats()`` method to ``MemoryBackend`` for consistency with ``RedisBackend`` +- Comprehensive test suite with 134 tests covering: + + - All five rate limiting algorithms with timing and concurrency tests + - Backend tests for Memory and SQLite with edge cases + - Decorator and middleware integration tests + - Exception handling and configuration validation + - End-to-end integration tests with FastAPI apps + +- ``httpx`` and ``pytest-asyncio`` as dev dependencies for testing + +Changed +^^^^^^^ + +- Improved documentation in README.md and DEVELOPMENT.md +- Added ``asyncio_default_fixture_loop_scope`` config for pytest-asyncio compatibility + +[0.1.0] - 2025-01-09 +-------------------- + +Initial release. + +Added +^^^^^ + +- Core rate limiting with ``@rate_limit`` decorator +- Five algorithms: + + - Token Bucket + - Sliding Window + - Fixed Window + - Leaky Bucket + - Sliding Window Counter + +- Three storage backends: + + - Memory (default) — In-memory with LRU eviction + - SQLite — Persistent storage with WAL mode + - Redis — Distributed storage with Lua scripts + +- Middleware support for global rate limiting via ``RateLimitMiddleware`` +- Dependency injection support with ``RateLimitDependency`` +- Custom key extractors for flexible rate limit grouping (by IP, API key, user, etc.) +- Configurable exemptions with ``exempt_when`` callback +- Rate limit headers (``X-RateLimit-Limit``, ``X-RateLimit-Remaining``, ``X-RateLimit-Reset``) +- ``RateLimitExceeded`` exception with ``retry_after`` and ``limit_info`` +- Full async support throughout +- Strict type hints (pyright/mypy compatible) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..1dcdc17 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,103 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import sys +from pathlib import Path + +# Add the project root to the path so autodoc can find the modules +sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) + +# -- Project information ----------------------------------------------------- +project = "fastapi-traffic" +copyright = "2026, zanewalker" +author = "zanewalker" +release = "0.2.1" +version = "0.2.1" + +# -- General configuration --------------------------------------------------- +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinx.ext.autosummary", + "sphinx_copybutton", + "sphinx_design", + "myst_parser", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The suffix(es) of source filenames. +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +# The master toctree document. +master_doc = "index" + +# -- Options for HTML output ------------------------------------------------- +html_theme = "furo" +html_title = "fastapi-traffic" +html_static_path = ["_static"] + +html_theme_options = { + "light_css_variables": { + "color-brand-primary": "#009485", + "color-brand-content": "#009485", + }, + "dark_css_variables": { + "color-brand-primary": "#00d4aa", + "color-brand-content": "#00d4aa", + }, + "sidebar_hide_name": False, + "navigation_with_keys": True, +} + +# -- Options for autodoc ----------------------------------------------------- +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "special-members": "__init__", + "undoc-members": True, + "exclude-members": "__weakref__", +} + +autodoc_typehints = "description" +autodoc_class_signature = "separated" + +# -- Options for Napoleon (Google/NumPy docstrings) -------------------------- +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_include_private_with_doc = False +napoleon_include_special_with_doc = True +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = True +napoleon_use_ivar = False +napoleon_use_param = True +napoleon_use_rtype = True +napoleon_preprocess_types = False +napoleon_type_aliases = None +napoleon_attr_annotations = True + +# -- Options for intersphinx ------------------------------------------------- +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "starlette": ("https://www.starlette.io", None), + "fastapi": ("https://fastapi.tiangolo.com", None), +} + +# -- MyST Parser options ----------------------------------------------------- +myst_enable_extensions = [ + "colon_fence", + "deflist", + "fieldlist", + "tasklist", +] +myst_heading_anchors = 3 diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..5b69e21 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,204 @@ +Contributing +============ + +Thanks for your interest in contributing to FastAPI Traffic! This guide will help +you get started. + +Development Setup +----------------- + +1. **Clone the repository:** + + .. code-block:: bash + + git clone https://gitlab.com/zanewalker/fastapi-traffic.git + cd fastapi-traffic + +2. **Install uv** (if you don't have it): + + .. code-block:: bash + + curl -LsSf https://astral.sh/uv/install.sh | sh + +3. **Create a virtual environment and install dependencies:** + + .. code-block:: bash + + uv venv + source .venv/bin/activate # or .venv\Scripts\activate on Windows + uv pip install -e ".[dev]" + +4. **Verify everything works:** + + .. code-block:: bash + + pytest + +Running Tests +------------- + +Run the full test suite: + +.. code-block:: bash + + pytest + +Run with coverage: + +.. code-block:: bash + + pytest --cov=fastapi_traffic --cov-report=html + +Run specific tests: + +.. code-block:: bash + + pytest tests/test_algorithms.py + pytest -k "test_token_bucket" + +Code Style +---------- + +We use ruff for linting and formatting: + +.. code-block:: bash + + # Check for issues + ruff check . + + # Auto-fix issues + ruff check --fix . + + # Format code + ruff format . + +Type Checking +------------- + +We use pyright for type checking: + +.. code-block:: bash + + pyright + +The codebase is strictly typed. All public APIs should have complete type hints. + +Making Changes +-------------- + +1. **Create a branch:** + + .. code-block:: bash + + git checkout -b feature/my-feature + +2. **Make your changes.** Follow the existing code style. + +3. **Add tests.** All new features should have tests. + +4. **Run the checks:** + + .. code-block:: bash + + ruff check . + ruff format . + pyright + pytest + +5. **Commit your changes:** + + .. code-block:: bash + + git commit -m "feat: add my feature" + + We follow `Conventional Commits `_: + + - ``feat:`` New features + - ``fix:`` Bug fixes + - ``docs:`` Documentation changes + - ``style:`` Code style changes (formatting, etc.) + - ``refactor:`` Code refactoring + - ``test:`` Adding or updating tests + - ``chore:`` Maintenance tasks + +6. **Push and create a merge request:** + + .. code-block:: bash + + git push origin feature/my-feature + +Project Structure +----------------- + +.. code-block:: text + + fastapi-traffic/ + ├── fastapi_traffic/ + │ ├── __init__.py # Public API exports + │ ├── exceptions.py # Custom exceptions + │ ├── middleware.py # Rate limit middleware + │ ├── backends/ + │ │ ├── base.py # Backend abstract class + │ │ ├── memory.py # In-memory backend + │ │ ├── sqlite.py # SQLite backend + │ │ └── redis.py # Redis backend + │ └── core/ + │ ├── algorithms.py # Rate limiting algorithms + │ ├── config.py # Configuration classes + │ ├── config_loader.py # Configuration loading + │ ├── decorator.py # @rate_limit decorator + │ ├── limiter.py # Main RateLimiter class + │ └── models.py # Data models + ├── tests/ + │ ├── test_algorithms.py + │ ├── test_backends.py + │ ├── test_decorator.py + │ └── ... + ├── examples/ + │ ├── 01_quickstart.py + │ └── ... + └── docs/ + └── ... + +Guidelines +---------- + +**Code:** + +- Keep functions focused and small +- Use descriptive variable names +- Add docstrings to public functions and classes +- Follow existing patterns in the codebase + +**Tests:** + +- Test both happy path and edge cases +- Use descriptive test names +- Mock external dependencies (Redis, etc.) +- Keep tests fast and isolated + +**Documentation:** + +- Update docs when adding features +- Include code examples +- Keep language clear and concise + +Reporting Issues +---------------- + +Found a bug? Have a feature request? Please open an issue on GitLab: + +https://gitlab.com/zanewalker/fastapi-traffic/issues + +Include: + +- What you expected to happen +- What actually happened +- Steps to reproduce +- Python version and OS +- FastAPI Traffic version + +Questions? +---------- + +Feel free to open an issue for questions. We're happy to help! diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst new file mode 100644 index 0000000..e7f7ab3 --- /dev/null +++ b/docs/getting-started/installation.rst @@ -0,0 +1,105 @@ +Installation +============ + +FastAPI Traffic supports Python 3.10 and above. You can install it using pip, uv, or +any other Python package manager. + +Basic Installation +------------------ + +The basic installation includes the memory backend, which is perfect for development +and single-process applications: + +.. tab-set:: + + .. tab-item:: pip + + .. code-block:: bash + + pip install git+https://gitlab.com/zanewalker/fastapi-traffic.git + + .. tab-item:: uv + + .. code-block:: bash + + uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git + + .. tab-item:: poetry + + .. code-block:: bash + + poetry add git+https://gitlab.com/zanewalker/fastapi-traffic.git + +With Redis Support +------------------ + +If you're running a distributed system with multiple application instances, you'll +want the Redis backend: + +.. tab-set:: + + .. tab-item:: pip + + .. code-block:: bash + + pip install "git+https://gitlab.com/zanewalker/fastapi-traffic.git[redis]" + + .. tab-item:: uv + + .. code-block:: bash + + uv add "git+https://gitlab.com/zanewalker/fastapi-traffic.git[redis]" + +Everything +---------- + +Want it all? Install with the ``all`` extra: + +.. code-block:: bash + + pip install "git+https://gitlab.com/zanewalker/fastapi-traffic.git[all]" + +This includes Redis support and ensures FastAPI is installed as well. + +Dependencies +------------ + +FastAPI Traffic has minimal dependencies: + +- **pydantic** (>=2.0) — For configuration validation +- **starlette** (>=0.27.0) — The ASGI framework that FastAPI is built on + +Optional dependencies: + +- **redis** (>=5.0.0) — Required for the Redis backend +- **fastapi** (>=0.100.0) — While not strictly required (we work with Starlette directly), + you probably want this + +Verifying the Installation +-------------------------- + +After installation, you can verify everything is working: + +.. code-block:: python + + import fastapi_traffic + print(fastapi_traffic.__version__) + # Should print: 0.2.1 + +Or check which backends are available: + +.. code-block:: python + + from fastapi_traffic import MemoryBackend, SQLiteBackend + print("Memory and SQLite backends available!") + + try: + from fastapi_traffic import RedisBackend + print("Redis backend available!") + except ImportError: + print("Redis backend not installed (install with [redis] extra)") + +What's Next? +------------ + +Head over to the :doc:`quickstart` guide to start rate limiting your endpoints. diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst new file mode 100644 index 0000000..99cd773 --- /dev/null +++ b/docs/getting-started/quickstart.rst @@ -0,0 +1,220 @@ +Quickstart +========== + +Let's get rate limiting working in your FastAPI app. This guide covers the basics — +you'll have something running in under five minutes. + +Your First Rate Limit +--------------------- + +The simplest way to add rate limiting is with the ``@rate_limit`` decorator: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi_traffic import rate_limit + + app = FastAPI() + + @app.get("/api/hello") + @rate_limit(10, 60) # 10 requests per 60 seconds + async def hello(request: Request): + return {"message": "Hello, World!"} + +That's the whole thing. Let's break down what's happening: + +1. The decorator takes two arguments: ``limit`` (max requests) and ``window_size`` (in seconds) +2. Each client is identified by their IP address by default +3. When a client exceeds the limit, they get a 429 response with a ``Retry-After`` header + +.. note:: + + The ``request: Request`` parameter is required. FastAPI Traffic needs access to the + request to identify the client and track their usage. + +Testing It Out +-------------- + +Fire up your app and hit the endpoint a few times: + +.. code-block:: bash + + # Start your app + uvicorn main:app --reload + + # In another terminal, make some requests + curl -i http://localhost:8000/api/hello + +You'll see headers like these in the response: + +.. code-block:: http + + HTTP/1.1 200 OK + X-RateLimit-Limit: 10 + X-RateLimit-Remaining: 9 + X-RateLimit-Reset: 1709834400 + +After 10 requests, you'll get: + +.. code-block:: http + + HTTP/1.1 429 Too Many Requests + Retry-After: 45 + X-RateLimit-Limit: 10 + X-RateLimit-Remaining: 0 + +Choosing an Algorithm +--------------------- + +Different situations call for different rate limiting strategies. Here's a quick guide: + +.. code-block:: python + + from fastapi_traffic import rate_limit, Algorithm + + # Token Bucket - great for APIs that need burst handling + # Allows short bursts of traffic, then smooths out + @app.get("/api/burst-friendly") + @rate_limit(100, 60, algorithm=Algorithm.TOKEN_BUCKET, burst_size=20) + async def burst_endpoint(request: Request): + return {"status": "ok"} + + # Sliding Window - most accurate, but uses more memory + # Perfect when you need precise rate limiting + @app.get("/api/precise") + @rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW) + async def precise_endpoint(request: Request): + return {"status": "ok"} + + # Fixed Window - simple and efficient + # Good for most use cases, slight edge case at window boundaries + @app.get("/api/simple") + @rate_limit(100, 60, algorithm=Algorithm.FIXED_WINDOW) + async def simple_endpoint(request: Request): + return {"status": "ok"} + +See :doc:`/user-guide/algorithms` for a deep dive into each algorithm. + +Rate Limiting by API Key +------------------------ + +IP-based limiting is fine for public endpoints, but for authenticated APIs you +probably want to limit by API key: + +.. code-block:: python + + def get_api_key(request: Request) -> str: + """Extract API key from header, fall back to IP.""" + api_key = request.headers.get("X-API-Key") + if api_key: + return f"key:{api_key}" + # Fall back to IP for unauthenticated requests + return request.client.host if request.client else "unknown" + + @app.get("/api/data") + @rate_limit(1000, 3600, key_extractor=get_api_key) # 1000/hour per API key + async def get_data(request: Request): + return {"data": "sensitive stuff"} + +Global Rate Limiting with Middleware +------------------------------------ + +Sometimes you want a blanket rate limit across your entire API. That's what +middleware is for: + +.. code-block:: python + + from fastapi_traffic.middleware import RateLimitMiddleware + + app = FastAPI() + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_paths={"/health", "/docs", "/openapi.json"}, + ) + + # All endpoints now have a shared 1000 req/min limit + @app.get("/api/users") + async def get_users(): + return {"users": []} + + @app.get("/api/posts") + async def get_posts(): + return {"posts": []} + +Using a Persistent Backend +-------------------------- + +The default memory backend works great for development, but it doesn't survive +restarts and doesn't work across multiple processes. For production, use SQLite +or Redis: + +**SQLite** — Good for single-node deployments: + +.. code-block:: python + + from fastapi_traffic import RateLimiter, SQLiteBackend + from fastapi_traffic.core.limiter import set_limiter + + # Set up 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() + +**Redis** — Required for distributed systems: + +.. code-block:: python + + from fastapi_traffic import RateLimiter + from fastapi_traffic.backends.redis import RedisBackend + from fastapi_traffic.core.limiter import set_limiter + + @app.on_event("startup") + async def startup(): + backend = await RedisBackend.from_url("redis://localhost:6379/0") + limiter = RateLimiter(backend) + set_limiter(limiter) + await limiter.initialize() + +Handling Rate Limit Errors +-------------------------- + +By default, exceeding the rate limit raises a ``RateLimitExceeded`` exception that +returns a 429 response. You can customize this: + +.. code-block:: python + + from fastapi import Request + from fastapi.responses import JSONResponse + 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": "slow_down", + "message": "You're making too many requests. Take a breather.", + "retry_after": exc.retry_after, + }, + ) + +What's Next? +------------ + +You've got the basics down. Here's where to go from here: + +- :doc:`/user-guide/algorithms` — Understand when to use each algorithm +- :doc:`/user-guide/backends` — Learn about storage options +- :doc:`/user-guide/key-extractors` — Advanced client identification +- :doc:`/user-guide/configuration` — Load settings from files and environment variables diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..1ef26eb --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,148 @@ +FastAPI Traffic +=============== + +**Production-grade rate limiting for FastAPI that just works.** + +.. image:: https://img.shields.io/badge/python-3.10+-blue.svg + :target: https://www.python.org/downloads/ + +.. image:: https://img.shields.io/badge/license-Apache%202.0-green.svg + :target: https://www.apache.org/licenses/LICENSE-2.0 + +---- + +FastAPI Traffic is a rate limiting library designed for real-world FastAPI applications. +It gives you five battle-tested algorithms, three storage backends, and a clean API that +stays out of your way. + +Whether you're building a public API that needs to handle thousands of requests per second +or a small internal service that just needs basic protection, this library has you covered. + +Quick Example +------------- + +Here's how simple it is to add rate limiting to your FastAPI app: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi_traffic import rate_limit + + app = FastAPI() + + @app.get("/api/users") + @rate_limit(100, 60) # 100 requests per minute + async def get_users(request: Request): + return {"users": ["alice", "bob"]} + +That's it. Your endpoint is now rate limited. Clients get helpful headers telling them +how many requests they have left, and when they can try again if they hit the limit. + +Why FastAPI Traffic? +-------------------- + +Most rate limiting libraries fall into one of two camps: either they're too simple +(fixed window only, no persistence) or they're way too complicated (requires reading +a 50-page manual just to get started). + +We tried to hit the sweet spot: + +- **Five algorithms** — Pick the one that fits your use case. Token bucket for burst + handling, sliding window for precision, fixed window for simplicity. + +- **Three backends** — Memory for development, SQLite for single-node production, + Redis for distributed systems. + +- **Works how you'd expect** — Decorator for endpoints, middleware for global limits, + dependency injection if that's your style. + +- **Fully async** — Built from the ground up for async Python. No blocking calls, + no thread pool hacks. + +- **Type-checked** — Full type hints throughout. Works great with pyright and mypy. + +What's in the Box +----------------- + +.. grid:: 2 + :gutter: 3 + + .. grid-item-card:: 🚦 Rate Limiting + :link: getting-started/quickstart + :link-type: doc + + Decorator-based rate limiting with sensible defaults. + + .. grid-item-card:: 🔧 Algorithms + :link: user-guide/algorithms + :link-type: doc + + Token bucket, sliding window, fixed window, leaky bucket, and more. + + .. grid-item-card:: 💾 Backends + :link: user-guide/backends + :link-type: doc + + Memory, SQLite, and Redis storage options. + + .. grid-item-card:: ⚙️ Configuration + :link: user-guide/configuration + :link-type: doc + + Load settings from environment variables or config files. + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + :hidden: + + getting-started/installation + getting-started/quickstart + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + :hidden: + + user-guide/algorithms + user-guide/backends + user-guide/middleware + user-guide/configuration + user-guide/key-extractors + user-guide/exception-handling + +.. toctree:: + :maxdepth: 2 + :caption: Advanced Topics + :hidden: + + advanced/distributed-systems + advanced/performance + advanced/testing + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + :hidden: + + api/decorator + api/middleware + api/algorithms + api/backends + api/config + api/exceptions + +.. toctree:: + :maxdepth: 1 + :caption: Project + :hidden: + + changelog + contributing + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..589a642 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +sphinx>=7.0.0 +furo>=2024.0.0 +sphinx-copybutton>=0.5.0 +myst-parser>=2.0.0 +sphinx-design>=0.5.0 diff --git a/docs/user-guide/algorithms.rst b/docs/user-guide/algorithms.rst new file mode 100644 index 0000000..c5d642e --- /dev/null +++ b/docs/user-guide/algorithms.rst @@ -0,0 +1,290 @@ +Rate Limiting Algorithms +======================== + +FastAPI Traffic ships with five rate limiting algorithms. Each has its own strengths, +and picking the right one depends on what you're trying to achieve. + +This guide will help you understand the tradeoffs and choose wisely. + +Overview +-------- + +Here's the quick comparison: + +.. list-table:: + :header-rows: 1 + :widths: 20 40 40 + + * - Algorithm + - Best For + - Tradeoffs + * - **Token Bucket** + - APIs that need burst handling + - Allows temporary spikes above average rate + * - **Sliding Window** + - Precise rate limiting + - Higher memory usage + * - **Fixed Window** + - Simple, low-overhead limiting + - Boundary issues (2x burst at window edges) + * - **Leaky Bucket** + - Consistent throughput + - No burst handling + * - **Sliding Window Counter** + - General purpose (default) + - Good balance of precision and efficiency + +Token Bucket +------------ + +Think of this as a bucket that holds tokens. Each request consumes a token, and +tokens refill at a steady rate. If the bucket is empty, requests are rejected. + +.. code-block:: python + + from fastapi_traffic import rate_limit, Algorithm + + @app.get("/api/data") + @rate_limit( + 100, # 100 tokens refill per minute + 60, + algorithm=Algorithm.TOKEN_BUCKET, + burst_size=20, # bucket can hold up to 20 tokens + ) + async def get_data(request: Request): + return {"data": "here"} + +**How it works:** + +1. The bucket starts full (at ``burst_size`` capacity) +2. Each request removes one token +3. Tokens refill at ``limit / window_size`` per second +4. If no tokens are available, the request is rejected + +**When to use it:** + +- Your API has legitimate burst traffic (e.g., page loads that trigger multiple requests) +- You want to allow short spikes while maintaining an average rate +- Mobile apps that batch requests when coming online + +**Example scenario:** A mobile app that syncs data when it reconnects. You want to +allow it to catch up quickly, but not overwhelm your servers. + +Sliding Window +-------------- + +This algorithm tracks the exact timestamp of every request within the window. It's +the most accurate approach, but uses more memory. + +.. code-block:: python + + @app.get("/api/transactions") + @rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW) + async def get_transactions(request: Request): + return {"transactions": []} + +**How it works:** + +1. Every request timestamp is stored +2. When checking, we count requests in the last ``window_size`` seconds +3. Old timestamps are cleaned up automatically + +**When to use it:** + +- You need precise rate limiting (financial APIs, compliance requirements) +- Memory isn't a major concern +- The rate limit is relatively low (not millions of requests) + +**Tradeoffs:** + +- Memory usage grows with request volume +- Slightly more CPU for timestamp management + +Fixed Window +------------ + +The simplest algorithm. Divide time into fixed windows (e.g., every minute) and +count requests in each window. + +.. code-block:: python + + @app.get("/api/simple") + @rate_limit(100, 60, algorithm=Algorithm.FIXED_WINDOW) + async def simple_endpoint(request: Request): + return {"status": "ok"} + +**How it works:** + +1. Time is divided into fixed windows (0:00-1:00, 1:00-2:00, etc.) +2. Each request increments the counter for the current window +3. When the window changes, the counter resets + +**When to use it:** + +- You want the simplest, most efficient option +- Slight inaccuracy at window boundaries is acceptable +- High-volume scenarios where memory matters + +**The boundary problem:** + +A client could make 100 requests at 0:59 and another 100 at 1:01, effectively +getting 200 requests in 2 seconds. If this matters for your use case, use +sliding window counter instead. + +Leaky Bucket +------------ + +Imagine a bucket with a hole in the bottom. Requests fill the bucket, and it +"leaks" at a constant rate. If the bucket overflows, requests are rejected. + +.. code-block:: python + + @app.get("/api/steady") + @rate_limit( + 100, + 60, + algorithm=Algorithm.LEAKY_BUCKET, + burst_size=10, # bucket capacity + ) + async def steady_endpoint(request: Request): + return {"status": "ok"} + +**How it works:** + +1. The bucket has a maximum capacity (``burst_size``) +2. Each request adds "water" to the bucket +3. Water leaks out at ``limit / window_size`` per second +4. If the bucket would overflow, the request is rejected + +**When to use it:** + +- You need consistent, smooth throughput +- Downstream systems can't handle bursts +- Processing capacity is truly fixed (e.g., hardware limitations) + +**Difference from token bucket:** + +- Token bucket allows bursts up to the bucket size +- Leaky bucket smooths out traffic to a constant rate + +Sliding Window Counter +---------------------- + +This is the default algorithm, and it's a good choice for most use cases. It +combines the efficiency of fixed windows with better accuracy. + +.. code-block:: python + + @app.get("/api/default") + @rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW_COUNTER) + async def default_endpoint(request: Request): + return {"status": "ok"} + +**How it works:** + +1. Maintains counters for the current and previous windows +2. Calculates a weighted average based on how far into the current window we are +3. At 30 seconds into a 60-second window: ``count = prev_count * 0.5 + curr_count`` + +**When to use it:** + +- General purpose rate limiting +- You want better accuracy than fixed window without the memory cost of sliding window +- Most APIs fall into this category + +**Why it's the default:** + +It gives you 90% of the accuracy of sliding window with the memory efficiency of +fixed window. Unless you have specific requirements, this is probably what you want. + +Choosing the Right Algorithm +---------------------------- + +Here's a decision tree: + +1. **Do you need to allow bursts?** + + - Yes → Token Bucket + - No, I need smooth traffic → Leaky Bucket + +2. **Do you need exact precision?** + + - Yes, compliance/financial → Sliding Window + - No, good enough is fine → Continue + +3. **Is memory a concern?** + + - Yes, high volume → Fixed Window + - No → Sliding Window Counter (default) + +Performance Comparison +---------------------- + +All algorithms are O(1) for the check operation, but they differ in storage: + +.. list-table:: + :header-rows: 1 + + * - Algorithm + - Storage per Key + - Operations + * - Token Bucket + - 2 floats + - 1 read, 1 write + * - Sliding Window + - N timestamps + - 1 read, 1 write, cleanup + * - Fixed Window + - 1 int, 1 float + - 1 read, 1 write + * - Leaky Bucket + - 2 floats + - 1 read, 1 write + * - Sliding Window Counter + - 3 values + - 1 read, 1 write + +For most applications, the performance difference is negligible. Choose based on +behavior, not performance, unless you're handling millions of requests per second. + +Code Examples +------------- + +Here's a complete example showing all algorithms: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi_traffic import rate_limit, Algorithm + + app = FastAPI() + + # Burst-friendly endpoint + @app.get("/api/burst") + @rate_limit(100, 60, algorithm=Algorithm.TOKEN_BUCKET, burst_size=25) + async def burst_endpoint(request: Request): + return {"type": "token_bucket"} + + # Precise limiting + @app.get("/api/precise") + @rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW) + async def precise_endpoint(request: Request): + return {"type": "sliding_window"} + + # Simple and efficient + @app.get("/api/simple") + @rate_limit(100, 60, algorithm=Algorithm.FIXED_WINDOW) + async def simple_endpoint(request: Request): + return {"type": "fixed_window"} + + # Smooth throughput + @app.get("/api/steady") + @rate_limit(100, 60, algorithm=Algorithm.LEAKY_BUCKET) + async def steady_endpoint(request: Request): + return {"type": "leaky_bucket"} + + # Best of both worlds (default) + @app.get("/api/balanced") + @rate_limit(100, 60, algorithm=Algorithm.SLIDING_WINDOW_COUNTER) + async def balanced_endpoint(request: Request): + return {"type": "sliding_window_counter"} diff --git a/docs/user-guide/backends.rst b/docs/user-guide/backends.rst new file mode 100644 index 0000000..33e6b23 --- /dev/null +++ b/docs/user-guide/backends.rst @@ -0,0 +1,312 @@ +Storage Backends +================ + +FastAPI Traffic needs somewhere to store rate limit state — how many requests each +client has made, when their window resets, and so on. That's what backends are for. + +You have three options, each suited to different deployment scenarios. + +Choosing a Backend +------------------ + +Here's the quick guide: + +.. list-table:: + :header-rows: 1 + :widths: 20 30 50 + + * - Backend + - Use When + - Limitations + * - **Memory** + - Development, single-process apps + - Lost on restart, doesn't share across processes + * - **SQLite** + - Single-node production + - Doesn't share across machines + * - **Redis** + - Distributed systems, multiple nodes + - Requires Redis infrastructure + +Memory Backend +-------------- + +The default backend. It stores everything in memory using a dictionary with LRU +eviction and automatic TTL cleanup. + +.. code-block:: python + + from fastapi_traffic import MemoryBackend, RateLimiter + from fastapi_traffic.core.limiter import set_limiter + + # This is what happens by default, but you can configure it: + backend = MemoryBackend( + max_size=10000, # Maximum number of keys to store + cleanup_interval=60, # How often to clean expired entries (seconds) + ) + limiter = RateLimiter(backend) + set_limiter(limiter) + +**When to use it:** + +- Local development +- Single-process applications +- Testing and CI/CD pipelines +- When you don't need persistence + +**Limitations:** + +- State is lost when the process restarts +- Doesn't work with multiple workers (each worker has its own memory) +- Not suitable for ``gunicorn`` with multiple workers or Kubernetes pods + +**Memory management:** + +The backend automatically evicts old entries when it hits ``max_size``. It uses +LRU (Least Recently Used) eviction, so inactive clients get cleaned up first. + +SQLite Backend +-------------- + +For single-node production deployments where you need persistence. Rate limits +survive restarts and work across multiple processes on the same machine. + +.. code-block:: python + + from fastapi_traffic import SQLiteBackend, RateLimiter + from fastapi_traffic.core.limiter import set_limiter + + backend = SQLiteBackend( + "rate_limits.db", # Database file path + cleanup_interval=300, # Clean expired entries every 5 minutes + ) + 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() + +**When to use it:** + +- Single-server deployments +- When you need rate limits to survive restarts +- Multiple workers on the same machine (gunicorn, uvicorn with workers) +- When Redis is overkill for your use case + +**Performance notes:** + +- Uses WAL (Write-Ahead Logging) mode for better concurrent performance +- Connection pooling is handled automatically +- Writes are batched where possible + +**File location:** + +Put the database file somewhere persistent. For Docker deployments, mount a volume: + +.. code-block:: yaml + + # docker-compose.yml + services: + api: + volumes: + - ./data:/app/data + environment: + - RATE_LIMIT_DB=/app/data/rate_limits.db + +Redis Backend +------------- + +The go-to choice for distributed systems. All your application instances share +the same rate limit state. + +.. code-block:: python + + from fastapi_traffic import RateLimiter + from fastapi_traffic.backends.redis import RedisBackend + from fastapi_traffic.core.limiter import set_limiter + + @app.on_event("startup") + async def startup(): + backend = await RedisBackend.from_url( + "redis://localhost:6379/0", + key_prefix="myapp:ratelimit", # Optional prefix for all keys + ) + limiter = RateLimiter(backend) + set_limiter(limiter) + await limiter.initialize() + + @app.on_event("shutdown") + async def shutdown(): + await limiter.close() + +**When to use it:** + +- Multiple application instances (Kubernetes, load-balanced servers) +- When you need rate limits shared across your entire infrastructure +- High-availability requirements + +**Connection options:** + +.. code-block:: python + + # Simple connection + backend = await RedisBackend.from_url("redis://localhost:6379/0") + + # With authentication + backend = await RedisBackend.from_url("redis://:password@localhost:6379/0") + + # Redis Sentinel for HA + backend = await RedisBackend.from_url( + "redis://sentinel1:26379/0", + sentinel_master="mymaster", + ) + + # Redis Cluster + backend = await RedisBackend.from_url("redis://node1:6379,node2:6379,node3:6379/0") + +**Atomic operations:** + +The Redis backend uses Lua scripts to ensure atomic operations. This means rate +limit checks are accurate even under high concurrency — no race conditions. + +**Key expiration:** + +Keys automatically expire based on the rate limit window. You don't need to worry +about Redis filling up with stale data. + +Switching Backends +------------------ + +You can switch backends without changing your rate limiting code. Just configure +a different backend at startup: + +.. code-block:: python + + import os + from fastapi_traffic import RateLimiter, MemoryBackend, SQLiteBackend + from fastapi_traffic.core.limiter import set_limiter + + def get_backend(): + """Choose backend based on environment.""" + env = os.getenv("ENVIRONMENT", "development") + + if env == "production": + redis_url = os.getenv("REDIS_URL") + if redis_url: + from fastapi_traffic.backends.redis import RedisBackend + return RedisBackend.from_url(redis_url) + return SQLiteBackend("/app/data/rate_limits.db") + + return MemoryBackend() + + @app.on_event("startup") + async def startup(): + backend = await get_backend() + limiter = RateLimiter(backend) + set_limiter(limiter) + await limiter.initialize() + +Custom Backends +--------------- + +Need something different? Maybe you want to use PostgreSQL, DynamoDB, or some +other storage system. You can implement your own backend: + +.. code-block:: python + + from fastapi_traffic.backends.base import Backend + from typing import Any + + class MyCustomBackend(Backend): + async def get(self, key: str) -> dict[str, Any] | None: + """Retrieve state for a key.""" + # Your implementation here + pass + + async def set(self, key: str, value: dict[str, Any], *, ttl: float) -> None: + """Store state with TTL.""" + pass + + async def delete(self, key: str) -> None: + """Delete a key.""" + pass + + async def exists(self, key: str) -> bool: + """Check if key exists.""" + pass + + async def increment(self, key: str, amount: int = 1) -> int: + """Atomically increment a counter.""" + pass + + async def clear(self) -> None: + """Clear all data.""" + pass + + async def close(self) -> None: + """Clean up resources.""" + pass + +The key methods are ``get``, ``set``, and ``delete``. The state is stored as a +dictionary, and the backend is responsible for serialization. + +Backend Comparison +------------------ + +.. list-table:: + :header-rows: 1 + + * - Feature + - Memory + - SQLite + - Redis + * - Persistence + - ❌ + - ✅ + - ✅ + * - Multi-process + - ❌ + - ✅ + - ✅ + * - Multi-node + - ❌ + - ❌ + - ✅ + * - Setup complexity + - None + - Low + - Medium + * - Latency + - ~0.01ms + - ~0.1ms + - ~1ms + * - Dependencies + - None + - None + - redis package + +Best Practices +-------------- + +1. **Start with Memory, upgrade when needed.** Don't over-engineer. Memory is + fine for development and many production scenarios. + +2. **Use Redis for distributed systems.** If you have multiple application + instances, Redis is the only option that works correctly. + +3. **Handle backend errors gracefully.** Set ``skip_on_error=True`` if you'd + rather allow requests through than fail when the backend is down: + + .. code-block:: python + + @rate_limit(100, 60, skip_on_error=True) + async def endpoint(request: Request): + return {"status": "ok"} + +4. **Monitor your backend.** Keep an eye on memory usage (Memory backend), + disk space (SQLite), or Redis memory and connections. diff --git a/docs/user-guide/configuration.rst b/docs/user-guide/configuration.rst new file mode 100644 index 0000000..34efcfc --- /dev/null +++ b/docs/user-guide/configuration.rst @@ -0,0 +1,315 @@ +Configuration +============= + +FastAPI Traffic supports loading configuration from environment variables and files. +This makes it easy to manage settings across different environments without changing code. + +Configuration Loader +-------------------- + +The ``ConfigLoader`` class handles loading configuration from various sources: + +.. code-block:: python + + from fastapi_traffic import ConfigLoader, RateLimitConfig + + loader = ConfigLoader() + + # Load from environment variables + config = loader.load_rate_limit_config_from_env() + + # Load from a JSON file + config = loader.load_rate_limit_config_from_json("config/rate_limits.json") + + # Load from a .env file + config = loader.load_rate_limit_config_from_env_file(".env") + +Environment Variables +--------------------- + +Set rate limit configuration using environment variables with the ``FASTAPI_TRAFFIC_`` +prefix: + +.. code-block:: bash + + # Basic settings + export FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 + export FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60 + export FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window_counter + + # Optional settings + export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapp + export FASTAPI_TRAFFIC_RATE_LIMIT_BURST_SIZE=20 + export FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS=true + export FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Too many requests" + export FASTAPI_TRAFFIC_RATE_LIMIT_STATUS_CODE=429 + export FASTAPI_TRAFFIC_RATE_LIMIT_SKIP_ON_ERROR=false + export FASTAPI_TRAFFIC_RATE_LIMIT_COST=1 + +Then load them in your app: + +.. code-block:: python + + from fastapi_traffic import load_rate_limit_config_from_env, rate_limit + + # Load config from environment + config = load_rate_limit_config_from_env() + + # Use it with the decorator + @app.get("/api/data") + @rate_limit(config.limit, config.window_size, algorithm=config.algorithm) + async def get_data(request: Request): + return {"data": "here"} + +Custom Prefix +------------- + +If ``FASTAPI_TRAFFIC_`` conflicts with something else, use a custom prefix: + +.. code-block:: python + + loader = ConfigLoader(prefix="MYAPP_RATELIMIT") + config = loader.load_rate_limit_config_from_env() + + # Now reads from: + # MYAPP_RATELIMIT_RATE_LIMIT_LIMIT=100 + # MYAPP_RATELIMIT_RATE_LIMIT_WINDOW_SIZE=60 + # etc. + +JSON Configuration +------------------ + +For more complex setups, use a JSON file: + +.. code-block:: json + + { + "limit": 100, + "window_size": 60, + "algorithm": "token_bucket", + "burst_size": 25, + "key_prefix": "api", + "include_headers": true, + "error_message": "Rate limit exceeded. Please slow down.", + "status_code": 429, + "skip_on_error": false, + "cost": 1 + } + +Load it: + +.. code-block:: python + + from fastapi_traffic import ConfigLoader + + loader = ConfigLoader() + config = loader.load_rate_limit_config_from_json("config/rate_limits.json") + +.env Files +---------- + +You can also use ``.env`` files, which is handy for local development: + +.. code-block:: bash + + # .env + FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100 + FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE=60 + FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window + +Load it: + +.. code-block:: python + + loader = ConfigLoader() + config = loader.load_rate_limit_config_from_env_file(".env") + +Global Configuration +-------------------- + +Besides per-endpoint configuration, you can set global defaults: + +.. code-block:: bash + + # Global settings + export FASTAPI_TRAFFIC_GLOBAL_ENABLED=true + export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_LIMIT=100 + export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_WINDOW_SIZE=60 + export FASTAPI_TRAFFIC_GLOBAL_DEFAULT_ALGORITHM=sliding_window_counter + export FASTAPI_TRAFFIC_GLOBAL_KEY_PREFIX=fastapi_traffic + export FASTAPI_TRAFFIC_GLOBAL_INCLUDE_HEADERS=true + export FASTAPI_TRAFFIC_GLOBAL_ERROR_MESSAGE="Rate limit exceeded" + export FASTAPI_TRAFFIC_GLOBAL_STATUS_CODE=429 + export FASTAPI_TRAFFIC_GLOBAL_SKIP_ON_ERROR=false + export FASTAPI_TRAFFIC_GLOBAL_HEADERS_PREFIX=X-RateLimit + +Load global config: + +.. code-block:: python + + from fastapi_traffic import load_global_config_from_env, RateLimiter + from fastapi_traffic.core.limiter import set_limiter + + global_config = load_global_config_from_env() + limiter = RateLimiter(config=global_config) + set_limiter(limiter) + +Auto-Detection +-------------- + +The convenience functions automatically detect file format: + +.. code-block:: python + + from fastapi_traffic import load_rate_limit_config, load_global_config + + # Detects JSON by extension + config = load_rate_limit_config("config/limits.json") + + # Detects .env file + config = load_rate_limit_config("config/.env") + + # Works for global config too + global_config = load_global_config("config/global.json") + +Overriding Values +----------------- + +You can override loaded values programmatically: + +.. code-block:: python + + loader = ConfigLoader() + + # Load base config from file + config = loader.load_rate_limit_config_from_json( + "config/base.json", + limit=200, # Override the limit + key_prefix="custom", # Override the prefix + ) + +This is useful for environment-specific overrides: + +.. code-block:: python + + import os + + base_config = loader.load_rate_limit_config_from_json("config/base.json") + + # Apply environment-specific overrides + if os.getenv("ENVIRONMENT") == "production": + config = loader.load_rate_limit_config_from_json( + "config/base.json", + limit=base_config.limit * 2, # Double the limit in production + ) + +Validation +---------- + +Configuration is validated when loaded. Invalid values raise ``ConfigurationError``: + +.. code-block:: python + + from fastapi_traffic import ConfigLoader, ConfigurationError + + loader = ConfigLoader() + + try: + config = loader.load_rate_limit_config_from_env() + except ConfigurationError as e: + print(f"Invalid configuration: {e}") + # Handle the error appropriately + +Common validation errors: + +- ``limit`` must be a positive integer +- ``window_size`` must be a positive number +- ``algorithm`` must be one of the valid algorithm names +- ``status_code`` must be a valid HTTP status code + +Algorithm Names +--------------- + +When specifying algorithms in configuration, use these names: + +.. list-table:: + :header-rows: 1 + + * - Config Value + - Algorithm + * - ``token_bucket`` + - Token Bucket + * - ``sliding_window`` + - Sliding Window + * - ``fixed_window`` + - Fixed Window + * - ``leaky_bucket`` + - Leaky Bucket + * - ``sliding_window_counter`` + - Sliding Window Counter (default) + +Boolean Values +-------------- + +Boolean settings accept various formats: + +- **True:** ``true``, ``1``, ``yes``, ``on`` +- **False:** ``false``, ``0``, ``no``, ``off`` + +Case doesn't matter. + +Complete Example +---------------- + +Here's a full example showing configuration loading in a real app: + +.. code-block:: python + + import os + from fastapi import FastAPI, Request + from fastapi_traffic import ( + ConfigLoader, + ConfigurationError, + RateLimiter, + rate_limit, + ) + from fastapi_traffic.core.limiter import set_limiter + + app = FastAPI() + + @app.on_event("startup") + async def startup(): + loader = ConfigLoader() + + try: + # Try to load from environment first + global_config = loader.load_global_config_from_env() + except ConfigurationError: + # Fall back to defaults + global_config = None + + limiter = RateLimiter(config=global_config) + set_limiter(limiter) + await limiter.initialize() + + @app.get("/api/data") + @rate_limit(100, 60) + async def get_data(request: Request): + return {"data": "here"} + + # Or load endpoint-specific config + loader = ConfigLoader() + try: + api_config = loader.load_rate_limit_config_from_json("config/api_limits.json") + except (FileNotFoundError, ConfigurationError): + api_config = None + + if api_config: + @app.get("/api/special") + @rate_limit( + api_config.limit, + api_config.window_size, + algorithm=api_config.algorithm, + ) + async def special_endpoint(request: Request): + return {"special": "data"} diff --git a/docs/user-guide/exception-handling.rst b/docs/user-guide/exception-handling.rst new file mode 100644 index 0000000..3da3bd5 --- /dev/null +++ b/docs/user-guide/exception-handling.rst @@ -0,0 +1,277 @@ +Exception Handling +================== + +When a client exceeds their rate limit, FastAPI Traffic raises a ``RateLimitExceeded`` +exception. This guide covers how to handle it gracefully. + +Default Behavior +---------------- + +By default, when a rate limit is exceeded, the library raises ``RateLimitExceeded``. +FastAPI will convert this to a 500 error unless you handle it. + +The exception contains useful information: + +.. code-block:: python + + from fastapi_traffic import RateLimitExceeded + + try: + # Rate limited operation + pass + except RateLimitExceeded as exc: + print(exc.message) # "Rate limit exceeded" + print(exc.retry_after) # Seconds until they can retry (e.g., 45.2) + print(exc.limit_info) # RateLimitInfo object with full details + +Custom Exception Handler +------------------------ + +The most common approach is to register a custom exception handler: + +.. code-block:: python + + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + from fastapi_traffic import RateLimitExceeded + + app = FastAPI() + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": "You're making too many requests. Please slow down.", + "retry_after": exc.retry_after, + }, + headers={ + "Retry-After": str(int(exc.retry_after or 60)), + }, + ) + +Now clients get a clean JSON response instead of a generic error. + +Including Rate Limit Headers +---------------------------- + +The ``limit_info`` object can generate standard rate limit headers: + +.. code-block:: python + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + headers = {} + if exc.limit_info: + headers = exc.limit_info.to_headers() + + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "retry_after": exc.retry_after, + }, + headers=headers, + ) + +This adds headers like: + +.. code-block:: text + + X-RateLimit-Limit: 100 + X-RateLimit-Remaining: 0 + X-RateLimit-Reset: 1709834400 + Retry-After: 45 + +Different Responses for Different Endpoints +------------------------------------------- + +You might want different error messages for different parts of your API: + +.. code-block:: python + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + path = request.url.path + + if path.startswith("/api/v1/"): + # API clients get JSON + return JSONResponse( + status_code=429, + content={"error": "rate_limit_exceeded", "retry_after": exc.retry_after}, + ) + elif path.startswith("/web/"): + # Web users get a friendly HTML page + return HTMLResponse( + status_code=429, + content="

Slow down!

Please wait a moment before trying again.

", + ) + else: + # Default response + return JSONResponse( + status_code=429, + content={"detail": exc.message}, + ) + +Using the on_blocked Callback +----------------------------- + +Instead of (or in addition to) exception handling, you can use the ``on_blocked`` +callback to run code when a request is blocked: + +.. code-block:: python + + import logging + + logger = logging.getLogger(__name__) + + def log_blocked_request(request: Request, result): + """Log when a request is rate limited.""" + client_ip = request.client.host if request.client else "unknown" + logger.warning( + "Rate limit exceeded for %s on %s %s", + client_ip, + request.method, + request.url.path, + ) + + @app.get("/api/data") + @rate_limit(100, 60, on_blocked=log_blocked_request) + async def get_data(request: Request): + return {"data": "here"} + +The callback receives the request and the rate limit result. It runs before the +exception is raised. + +Exempting Certain Requests +-------------------------- + +Use ``exempt_when`` to skip rate limiting for certain requests: + +.. code-block:: python + + def is_admin(request: Request) -> bool: + """Check if request is from an admin.""" + user = getattr(request.state, "user", None) + return user is not None and user.is_admin + + @app.get("/api/data") + @rate_limit(100, 60, exempt_when=is_admin) + async def get_data(request: Request): + return {"data": "here"} + +Admin requests bypass rate limiting entirely. + +Graceful Degradation +-------------------- + +Sometimes you'd rather serve a degraded response than reject the request entirely: + +.. code-block:: python + + from fastapi_traffic import RateLimiter, RateLimitConfig + from fastapi_traffic.core.limiter import get_limiter + + @app.get("/api/search") + async def search(request: Request, q: str): + limiter = get_limiter() + config = RateLimitConfig(limit=100, window_size=60) + + result = await limiter.check(request, config) + + if not result.allowed: + # Return cached/simplified results instead of blocking + return { + "results": get_cached_results(q), + "note": "Results may be stale. Please try again later.", + "retry_after": result.info.retry_after, + } + + # Full search + return {"results": perform_full_search(q)} + +Backend Errors +-------------- + +If the rate limit backend fails (Redis down, SQLite locked, etc.), you have options: + +**Option 1: Fail closed (default)** + +Requests fail when the backend is unavailable. Safer, but impacts availability. + +**Option 2: Fail open** + +Allow requests through when the backend fails: + +.. code-block:: python + + @app.get("/api/data") + @rate_limit(100, 60, skip_on_error=True) + async def get_data(request: Request): + return {"data": "here"} + +**Option 3: Handle the error explicitly** + +.. code-block:: python + + from fastapi_traffic import BackendError + + @app.exception_handler(BackendError) + async def backend_error_handler(request: Request, exc: BackendError): + # Log the error + logger.error("Rate limit backend error: %s", exc.original_error) + + # Decide what to do + # Option A: Allow the request + return None # Let the request continue + + # Option B: Return an error + return JSONResponse( + status_code=503, + content={"error": "service_unavailable"}, + ) + +Other Exceptions +---------------- + +FastAPI Traffic defines a few exception types: + +.. code-block:: python + + from fastapi_traffic import ( + RateLimitExceeded, # Rate limit was exceeded + BackendError, # Storage backend failed + ConfigurationError, # Invalid configuration + ) + +All inherit from ``FastAPITrafficError``: + +.. code-block:: python + + from fastapi_traffic.exceptions import FastAPITrafficError + + @app.exception_handler(FastAPITrafficError) + async def traffic_error_handler(request: Request, exc: FastAPITrafficError): + """Catch-all for FastAPI Traffic errors.""" + if isinstance(exc, RateLimitExceeded): + return JSONResponse(status_code=429, content={"error": "rate_limited"}) + elif isinstance(exc, BackendError): + return JSONResponse(status_code=503, content={"error": "backend_error"}) + else: + return JSONResponse(status_code=500, content={"error": "internal_error"}) + +Helper Function +--------------- + +FastAPI Traffic provides a helper to create rate limit responses: + +.. code-block:: python + + from fastapi_traffic.core.decorator import create_rate_limit_response + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return create_rate_limit_response(exc, include_headers=True) + +This creates a standard 429 response with all the appropriate headers. diff --git a/docs/user-guide/key-extractors.rst b/docs/user-guide/key-extractors.rst new file mode 100644 index 0000000..e9ec03f --- /dev/null +++ b/docs/user-guide/key-extractors.rst @@ -0,0 +1,258 @@ +Key Extractors +============== + +A key extractor is a function that identifies who's making a request. By default, +FastAPI Traffic uses the client's IP address, but you can customize this to fit +your authentication model. + +How It Works +------------ + +Every rate limit needs a way to group requests. The key extractor returns a string +that identifies the client: + +.. code-block:: python + + def my_key_extractor(request: Request) -> str: + return "some-unique-identifier" + +All requests that return the same identifier share the same rate limit bucket. + +Default Behavior +---------------- + +The default extractor looks for the client IP in this order: + +1. ``X-Forwarded-For`` header (first IP in the list) +2. ``X-Real-IP`` header +3. Direct connection IP (``request.client.host``) +4. Falls back to ``"unknown"`` + +This handles most reverse proxy setups automatically. + +Rate Limiting by API Key +------------------------ + +For authenticated APIs, you probably want to limit by API key: + +.. code-block:: python + + from fastapi import Request + from fastapi_traffic import rate_limit + + def api_key_extractor(request: Request) -> str: + """Rate limit by API key.""" + api_key = request.headers.get("X-API-Key") + if api_key: + return f"apikey:{api_key}" + # Fall back to IP for unauthenticated requests + return f"ip:{request.client.host}" if request.client else "ip:unknown" + + @app.get("/api/data") + @rate_limit(1000, 3600, key_extractor=api_key_extractor) + async def get_data(request: Request): + return {"data": "here"} + +Now each API key gets its own rate limit bucket. + +Rate Limiting by User +--------------------- + +If you're using authentication middleware that sets the user: + +.. code-block:: python + + def user_extractor(request: Request) -> str: + """Rate limit by authenticated user.""" + # Assuming your auth middleware sets request.state.user + user = getattr(request.state, "user", None) + if user: + return f"user:{user.id}" + return f"ip:{request.client.host}" if request.client else "ip:unknown" + + @app.get("/api/profile") + @rate_limit(100, 60, key_extractor=user_extractor) + async def get_profile(request: Request): + return {"profile": "data"} + +Rate Limiting by Tenant +----------------------- + +For multi-tenant applications: + +.. code-block:: python + + def tenant_extractor(request: Request) -> str: + """Rate limit by tenant.""" + # From subdomain + host = request.headers.get("host", "") + if "." in host: + tenant = host.split(".")[0] + return f"tenant:{tenant}" + + # Or from header + tenant = request.headers.get("X-Tenant-ID") + if tenant: + return f"tenant:{tenant}" + + return "tenant:default" + +Combining Identifiers +--------------------- + +Sometimes you want to combine multiple factors: + +.. code-block:: python + + def combined_extractor(request: Request) -> str: + """Rate limit by user AND endpoint.""" + user = getattr(request.state, "user", None) + user_id = user.id if user else "anonymous" + endpoint = request.url.path + return f"{user_id}:{endpoint}" + +This gives each user a separate limit for each endpoint. + +Tiered Rate Limits +------------------ + +Different users might have different limits. Handle this with a custom extractor +that includes the tier: + +.. code-block:: python + + def tiered_extractor(request: Request) -> str: + """Include tier in the key for different limits.""" + user = getattr(request.state, "user", None) + if user: + # Premium users get a different bucket + tier = "premium" if user.is_premium else "free" + return f"{tier}:{user.id}" + return f"anonymous:{request.client.host}" + +Then apply different limits based on tier: + +.. code-block:: python + + # You'd typically do this with middleware or dependency injection + # to check the tier and apply the appropriate limit + + @app.get("/api/data") + async def get_data(request: Request): + user = getattr(request.state, "user", None) + if user and user.is_premium: + # Premium: 10000 req/hour + limit, window = 10000, 3600 + else: + # Free: 100 req/hour + limit, window = 100, 3600 + + # Apply rate limit manually + limiter = get_limiter() + config = RateLimitConfig(limit=limit, window_size=window) + await limiter.hit(request, config) + + return {"data": "here"} + +Geographic Rate Limiting +------------------------ + +Limit by country or region: + +.. code-block:: python + + def geo_extractor(request: Request) -> str: + """Rate limit by country.""" + # Assuming you have a GeoIP lookup + country = request.headers.get("CF-IPCountry", "XX") # Cloudflare header + ip = request.client.host if request.client else "unknown" + return f"{country}:{ip}" + +This lets you apply different limits to different regions if needed. + +Endpoint-Specific Keys +---------------------- + +Rate limit the same user differently per endpoint: + +.. code-block:: python + + def endpoint_user_extractor(request: Request) -> str: + """Separate limits per endpoint per user.""" + user = getattr(request.state, "user", None) + user_id = user.id if user else request.client.host + method = request.method + path = request.url.path + return f"{user_id}:{method}:{path}" + +Best Practices +-------------- + +1. **Always have a fallback.** If your primary identifier isn't available, fall + back to IP: + + .. code-block:: python + + def safe_extractor(request: Request) -> str: + api_key = request.headers.get("X-API-Key") + if api_key: + return f"key:{api_key}" + return f"ip:{request.client.host if request.client else 'unknown'}" + +2. **Use prefixes.** When mixing identifier types, prefix them to avoid collisions: + + .. code-block:: python + + # Good - clear what each key represents + return f"user:{user_id}" + return f"ip:{ip_address}" + return f"key:{api_key}" + + # Bad - could collide + return user_id + return ip_address + +3. **Keep it fast.** The extractor runs on every request. Avoid database lookups + or expensive operations: + + .. code-block:: python + + # Bad - database lookup on every request + def slow_extractor(request: Request) -> str: + user = db.get_user(request.headers.get("Authorization")) + return user.id + + # Good - use data already in the request + def fast_extractor(request: Request) -> str: + return request.state.user.id # Set by auth middleware + +4. **Be consistent.** The same client should always get the same key. Watch out + for things like: + + - IP addresses changing (mobile users) + - Case sensitivity (normalize to lowercase) + - Whitespace (strip it) + + .. code-block:: python + + def normalized_extractor(request: Request) -> str: + api_key = request.headers.get("X-API-Key", "").strip().lower() + if api_key: + return f"key:{api_key}" + return f"ip:{request.client.host}" + +Using with Middleware +--------------------- + +Key extractors work the same way with middleware: + +.. code-block:: python + + from fastapi_traffic.middleware import RateLimitMiddleware + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + key_extractor=api_key_extractor, + ) diff --git a/docs/user-guide/middleware.rst b/docs/user-guide/middleware.rst new file mode 100644 index 0000000..15675c3 --- /dev/null +++ b/docs/user-guide/middleware.rst @@ -0,0 +1,322 @@ +Middleware +========== + +Sometimes you want rate limiting applied to your entire API, not just individual +endpoints. That's where middleware comes in. + +Middleware sits between the client and your application, checking every request +before it reaches your endpoints. + +Basic Usage +----------- + +Add the middleware to your FastAPI app: + +.. code-block:: python + + from fastapi import FastAPI + from fastapi_traffic.middleware import RateLimitMiddleware + + app = FastAPI() + + app.add_middleware( + RateLimitMiddleware, + limit=1000, # 1000 requests + window_size=60, # per minute + ) + + @app.get("/api/users") + async def get_users(): + return {"users": []} + + @app.get("/api/posts") + async def get_posts(): + return {"posts": []} + +Now every endpoint shares the same rate limit pool. A client who makes 500 requests +to ``/api/users`` only has 500 left for ``/api/posts``. + +Exempting Paths +--------------- + +You probably don't want to rate limit your health checks or documentation: + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_paths={ + "/health", + "/ready", + "/docs", + "/redoc", + "/openapi.json", + }, + ) + +These paths bypass rate limiting entirely. + +Exempting IPs +------------- + +Internal services, monitoring systems, or your own infrastructure might need +unrestricted access: + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + exempt_ips={ + "127.0.0.1", + "10.0.0.0/8", # Internal network + "192.168.1.100", # Monitoring server + }, + ) + +.. note:: + + IP exemptions are checked against the client IP extracted by the key extractor. + Make sure your proxy headers are configured correctly if you're behind a load + balancer. + +Custom Key Extraction +--------------------- + +By default, clients are identified by IP address. You can change this: + +.. code-block:: python + + from starlette.requests import Request + + def get_client_id(request: Request) -> str: + """Identify clients by API key, fall back to IP.""" + api_key = request.headers.get("X-API-Key") + if api_key: + return f"api:{api_key}" + return request.client.host if request.client else "unknown" + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + key_extractor=get_client_id, + ) + +Choosing an Algorithm +--------------------- + +The middleware supports all five algorithms: + +.. code-block:: python + + from fastapi_traffic.core.algorithms import Algorithm + + # Token bucket for burst-friendly limiting + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + algorithm=Algorithm.TOKEN_BUCKET, + ) + + # Sliding window for precise limiting + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + algorithm=Algorithm.SLIDING_WINDOW, + ) + +Using a Custom Backend +---------------------- + +By default, middleware uses the memory backend. For production, you'll want +something persistent: + +.. code-block:: python + + from fastapi_traffic import SQLiteBackend + from fastapi_traffic.middleware import RateLimitMiddleware + + backend = SQLiteBackend("rate_limits.db") + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + backend=backend, + ) + + @app.on_event("shutdown") + async def shutdown(): + await backend.close() + +For Redis: + +.. code-block:: python + + from fastapi_traffic.backends.redis import RedisBackend + + # Create backend at startup + redis_backend = None + + @app.on_event("startup") + async def startup(): + global redis_backend + redis_backend = await RedisBackend.from_url("redis://localhost:6379/0") + + # Note: You'll need to configure middleware after startup + # or use a factory pattern + +Convenience Middleware Classes +------------------------------ + +For common use cases, we provide pre-configured middleware: + +.. code-block:: python + + from fastapi_traffic.middleware import ( + SlidingWindowMiddleware, + TokenBucketMiddleware, + ) + + # Sliding window algorithm + app.add_middleware( + SlidingWindowMiddleware, + limit=1000, + window_size=60, + ) + + # Token bucket algorithm + app.add_middleware( + TokenBucketMiddleware, + limit=1000, + window_size=60, + ) + +Combining with Decorator +------------------------ + +You can use both middleware and decorators. The middleware provides a baseline +limit, and decorators can add stricter limits to specific endpoints: + +.. code-block:: python + + from fastapi_traffic import rate_limit + from fastapi_traffic.middleware import RateLimitMiddleware + + # Global limit: 1000 req/min + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + ) + + # This endpoint has an additional, stricter limit + @app.post("/api/expensive-operation") + @rate_limit(10, 60) # Only 10 req/min for this endpoint + async def expensive_operation(request: Request): + return {"result": "done"} + + # This endpoint uses only the global limit + @app.get("/api/cheap-operation") + async def cheap_operation(): + return {"result": "done"} + +Both limits are checked. A request must pass both the middleware limit AND the +decorator limit. + +Error Responses +--------------- + +When a client exceeds the rate limit, they get a 429 response: + +.. code-block:: json + + { + "detail": "Rate limit exceeded. Please try again later.", + "retry_after": 45.2 + } + +You can customize the message: + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + error_message="Whoa there! You're making requests too fast.", + status_code=429, + ) + +Response Headers +---------------- + +By default, rate limit headers are included in every response: + +.. code-block:: http + + X-RateLimit-Limit: 1000 + X-RateLimit-Remaining: 847 + X-RateLimit-Reset: 1709834400 + +When rate limited: + +.. code-block:: http + + Retry-After: 45 + +Disable headers if you don't want to expose this information: + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + include_headers=False, + ) + +Handling Backend Errors +----------------------- + +What happens if your Redis server goes down? By default, the middleware will +raise an exception. You can change this behavior: + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, + window_size=60, + skip_on_error=True, # Allow requests through if backend fails + ) + +With ``skip_on_error=True``, requests are allowed through when the backend is +unavailable. This is a tradeoff between availability and protection. + +Full Configuration Reference +---------------------------- + +.. code-block:: python + + app.add_middleware( + RateLimitMiddleware, + limit=1000, # Max requests per window + window_size=60.0, # Window size in seconds + algorithm=Algorithm.SLIDING_WINDOW_COUNTER, # Algorithm to use + backend=None, # Storage backend (default: MemoryBackend) + key_prefix="middleware", # Prefix for rate limit keys + include_headers=True, # Add rate limit headers to responses + error_message="Rate limit exceeded. Please try again later.", + status_code=429, # HTTP status when limited + skip_on_error=False, # Allow requests if backend fails + exempt_paths=None, # Set of paths to exempt + exempt_ips=None, # Set of IPs to exempt + key_extractor=default_key_extractor, # Function to identify clients + ) diff --git a/examples/basic_usage.py b/examples/00_basic_usage.py similarity index 80% rename from examples/basic_usage.py rename to examples/00_basic_usage.py index 07cebbe..e0f8d33 100644 --- a/examples/basic_usage.py +++ b/examples/00_basic_usage.py @@ -1,9 +1,8 @@ """Basic usage examples for fastapi-traffic.""" -from __future__ import annotations - +from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING +from typing import Annotated, TypeAlias from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse @@ -17,15 +16,19 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.decorator import RateLimitDependency from fastapi_traffic.core.limiter import set_limiter +from fastapi_traffic.core.models import RateLimitInfo -if TYPE_CHECKING: - from collections.abc import AsyncIterator +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 # Configure global rate limiter with SQLite backend for persistence backend = SQLiteBackend("rate_limits.db") limiter = RateLimiter(backend) set_limiter(limiter) +basic_ratelimiter = RateLimitDependency(limit=20, window_size=60) +RateLimitDep: TypeAlias = Annotated[RateLimitInfo, Depends(basic_ratelimiter)] + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: @@ -108,19 +111,18 @@ async def api_key_endpoint(_: Request) -> dict[str, str]: # Example 5: Using dependency injection -rate_limit_dep = RateLimitDependency(limit=20, window_size=60) - - -@app.get("/api/dependency") +# Note: This dependency injection seems to be tripping pydantic, needs to be looked into. +"""@app.get("/api/dependency") async def dependency_endpoint( _: Request, - rate_info: dict[str, object] = Depends(rate_limit_dep), + rate_info: RateLimitDep, ) -> dict[str, object]: - """Endpoint using rate limit as dependency.""" + '''Endpoint using rate limit as dependency.''' return { "message": "Rate limit info available", "rate_limit": rate_info, } +""" # Example 6: Exempt certain requests @@ -163,12 +165,30 @@ async def expensive_endpoint(_: Request) -> dict[str, str]: @app.get("/health") -async def health_check() -> dict[str, str]: +async def health_check(_: Request) -> dict[str, str]: """Health check endpoint (typically exempt from rate limiting).""" return {"status": "healthy"} if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser( + description="Basic usage example for fastapi-traffic" + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/01_quickstart.py b/examples/01_quickstart.py index f1c03ec..03ae21f 100644 --- a/examples/01_quickstart.py +++ b/examples/01_quickstart.py @@ -15,6 +15,9 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + # Step 1: Create a backend and limiter backend = MemoryBackend() limiter = RateLimiter(backend) @@ -55,6 +58,24 @@ async def get_data(_: Request) -> dict[str, str]: if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="127.0.0.1", port=8002) + parser = argparse.ArgumentParser( + description="Quickstart example for fastapi-traffic" + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/02_algorithms.py b/examples/02_algorithms.py index 1b155d3..497c0ae 100644 --- a/examples/02_algorithms.py +++ b/examples/02_algorithms.py @@ -16,6 +16,9 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + backend = MemoryBackend() limiter = RateLimiter(backend) @@ -126,6 +129,22 @@ async def leaky_bucket(_: Request) -> dict[str, str]: if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser(description="Rate limiting algorithms example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/03_backends.py b/examples/03_backends.py index 725e1b0..06b7f24 100644 --- a/examples/03_backends.py +++ b/examples/03_backends.py @@ -18,6 +18,9 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + # Choose backend based on environment def get_backend(): @@ -100,10 +103,26 @@ async def backend_info() -> dict[str, Any]: if __name__ == "__main__": + import argparse + import uvicorn + parser = argparse.ArgumentParser(description="Storage backends example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + # 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) + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/04_key_extractors.py b/examples/04_key_extractors.py index d6b12d9..a3fa1e5 100644 --- a/examples/04_key_extractors.py +++ b/examples/04_key_extractors.py @@ -15,6 +15,9 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + backend = MemoryBackend() limiter = RateLimiter(backend) @@ -151,6 +154,22 @@ async def user_action( if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser(description="Custom key extractors example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/05_middleware.py b/examples/05_middleware.py index 98acec8..d7c0281 100644 --- a/examples/05_middleware.py +++ b/examples/05_middleware.py @@ -11,6 +11,10 @@ from fastapi_traffic.middleware import RateLimitMiddleware # from fastapi_traffic.middleware import SlidingWindowMiddleware # from fastapi_traffic.middleware import TokenBucketMiddleware + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8001 + app = FastAPI(title="Middleware Rate Limiting") @@ -104,6 +108,22 @@ async def docs_info() -> dict[str, str]: if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser(description="Middleware rate limiting example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/06_dependency_injection.py b/examples/06_dependency_injection.py index 7228623..5ebce99 100644 --- a/examples/06_dependency_injection.py +++ b/examples/06_dependency_injection.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import asynccontextmanager -from typing import Any +from typing import Annotated, Any, TypeAlias from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse @@ -15,6 +15,10 @@ from fastapi_traffic import ( ) from fastapi_traffic.core.decorator import RateLimitDependency from fastapi_traffic.core.limiter import set_limiter +from fastapi_traffic.core.models import RateLimitInfo + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 backend = MemoryBackend() limiter = RateLimiter(backend) @@ -43,29 +47,6 @@ async def rate_limit_handler(_: Request, exc: RateLimitExceeded) -> JSONResponse # 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, - 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, @@ -84,11 +65,38 @@ enterprise_tier_limit = RateLimitDependency( key_prefix="enterprise", ) +BasicRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(basic_rate_limit)] + + +@app.get("/basic") +async def basic_endpoint( + _: Request, + rate_info: BasicRateLimit, +) -> 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") + + +TierDep: TypeAlias = Annotated[str, Depends(get_user_tier)] + async def tiered_rate_limit( request: Request, - tier: str = Depends(get_user_tier), -) -> Any: + tier: TierDep, +) -> RateLimitInfo: """Apply different rate limits based on user tier.""" if tier == "enterprise": return await enterprise_tier_limit(request) @@ -98,10 +106,13 @@ async def tiered_rate_limit( return await free_tier_limit(request) +TieredRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(tiered_rate_limit)] + + @app.get("/tiered") async def tiered_endpoint( request: Request, - rate_info: Any = Depends(tiered_rate_limit), + rate_info: TieredRateLimit, ) -> dict[str, Any]: """Endpoint with tier-based rate limiting.""" tier = get_user_tier(request) @@ -129,10 +140,13 @@ api_rate_limit = RateLimitDependency( ) +ApiRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(api_rate_limit)] + + @app.get("/api/resource") async def api_resource( _: Request, - rate_info: Any = Depends(api_rate_limit), + rate_info: ApiRateLimit, ) -> dict[str, Any]: """API endpoint with per-API-key rate limiting.""" return { @@ -155,10 +169,14 @@ per_hour_limit = RateLimitDependency( ) +PerMinuteLimit: TypeAlias = Annotated[RateLimitInfo, Depends(per_minute_limit)] +PerHourLimit: TypeAlias = Annotated[RateLimitInfo, Depends(per_hour_limit)] + + async def combined_rate_limit( _: Request, - minute_info: Any = Depends(per_minute_limit), - hour_info: Any = Depends(per_hour_limit), + minute_info: PerMinuteLimit, + hour_info: PerHourLimit, ) -> dict[str, Any]: """Apply both per-minute and per-hour limits.""" return { @@ -173,10 +191,13 @@ async def combined_rate_limit( } +CombinedRateLimit: TypeAlias = Annotated[dict[str, Any], Depends(combined_rate_limit)] + + @app.get("/combined") async def combined_endpoint( _: Request, - rate_info: dict[str, Any] = Depends(combined_rate_limit), + rate_info: CombinedRateLimit, ) -> dict[str, Any]: """Endpoint with multiple rate limit tiers.""" return { @@ -199,10 +220,15 @@ internal_exempt_limit = RateLimitDependency( ) +InternalExemptLimit: TypeAlias = Annotated[ + RateLimitInfo, Depends(internal_exempt_limit) +] + + @app.get("/internal-exempt") async def internal_exempt_endpoint( request: Request, - rate_info: Any = Depends(internal_exempt_limit), + rate_info: InternalExemptLimit, ) -> dict[str, Any]: """Internal requests are exempt from rate limiting.""" is_internal = is_internal_request(request) @@ -220,6 +246,22 @@ async def internal_exempt_endpoint( if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser(description="Dependency injection example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/07_redis_distributed.py b/examples/07_redis_distributed.py index 622b7e5..96c585a 100644 --- a/examples/07_redis_distributed.py +++ b/examples/07_redis_distributed.py @@ -29,13 +29,18 @@ from fastapi_traffic import ( from fastapi_traffic.backends.redis import RedisBackend from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8001 + 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") + redis_url = os.getenv( + "REDIS_URL", "redis://localhost:6379/0" + ) # tip: `docker run -d --name my-redis -p 6379:6379 redis:latest` to start a redis instance in docker and access it on redis://0.0.0.0:6379/0 backend = await RedisBackend.from_url( redis_url, key_prefix="myapp", @@ -188,10 +193,28 @@ async def stats(backend: BackendDep) -> dict[str, object]: if __name__ == "__main__": + import argparse + import uvicorn + parser = argparse.ArgumentParser( + description="Redis distributed rate limiting example" + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + # 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) + # uvicorn 07_redis_distributed:app --port 8002 + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/08_tiered_api.py b/examples/08_tiered_api.py index 547085e..6cfbd07 100644 --- a/examples/08_tiered_api.py +++ b/examples/08_tiered_api.py @@ -19,6 +19,9 @@ from fastapi_traffic import ( from fastapi_traffic.core.decorator import RateLimitDependency from fastapi_traffic.core.limiter import set_limiter +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + backend = MemoryBackend() limiter = RateLimiter(backend) @@ -261,9 +264,25 @@ async def pricing() -> dict[str, Any]: if __name__ == "__main__": + import argparse + import uvicorn + parser = argparse.ArgumentParser(description="Tiered API example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + # 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) + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/09_custom_responses.py b/examples/09_custom_responses.py index 7df6111..cfad4d6 100644 --- a/examples/09_custom_responses.py +++ b/examples/09_custom_responses.py @@ -33,6 +33,9 @@ async def lifespan(_: FastAPI): await limiter.close() +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + app = FastAPI(title="Custom Responses Example", lifespan=lifespan) @@ -211,6 +214,22 @@ async def graceful_endpoint(_: Request) -> dict[str, str]: if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + parser = argparse.ArgumentParser(description="Custom responses example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/10_advanced_patterns.py b/examples/10_advanced_patterns.py index 7c41923..29448e4 100644 --- a/examples/10_advanced_patterns.py +++ b/examples/10_advanced_patterns.py @@ -32,6 +32,9 @@ async def lifespan(_: FastAPI): await limiter.close() +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8000 + app = FastAPI(title="Advanced Patterns", lifespan=lifespan) @@ -327,6 +330,22 @@ async def cascading_endpoint( if __name__ == "__main__": + import argparse + import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8001) + parser = argparse.ArgumentParser(description="Advanced patterns example") + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Host to bind to (default: {DEFAULT_HOST})", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Port to bind to (default: {DEFAULT_PORT})", + ) + args = parser.parse_args() + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/11_config_loader.py b/examples/11_config_loader.py index 5cef1a2..0737e93 100644 --- a/examples/11_config_loader.py +++ b/examples/11_config_loader.py @@ -7,6 +7,7 @@ making it easy to manage settings across different environments (dev, staging, p from __future__ import annotations import json +import logging import os import tempfile from contextlib import asynccontextmanager @@ -44,7 +45,6 @@ def example_env_variables() -> RateLimitConfig: export FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM=sliding_window_counter export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapi """ - # Using the convenience function config = load_rate_limit_config_from_env( # You can provide overrides for values not in env vars limit=50, # Default if FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT not set @@ -93,7 +93,7 @@ def example_dotenv_file() -> RateLimitConfig: FASTAPI_TRAFFIC_RATE_LIMIT_INCLUDE_HEADERS=true FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Too many requests, please slow down" """ - # Create a sample .env file for demonstration + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: f.write("# Rate limit configuration\n") f.write("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100\n") @@ -104,7 +104,7 @@ def example_dotenv_file() -> RateLimitConfig: env_path = f.name try: - # Load using auto-detection (detects .env suffix) + config = load_rate_limit_config(env_path) print(f"From .env: limit={config.limit}, algorithm={config.algorithm}") print(f"Burst size: {config.burst_size}") @@ -132,7 +132,6 @@ def example_json_file() -> RateLimitConfig: "cost": 1 } """ - # Create a sample JSON file for demonstration config_data = { "limit": 500, "window_size": 300.0, @@ -148,7 +147,7 @@ def example_json_file() -> RateLimitConfig: json_path = f.name try: - # Load using auto-detection (detects .json suffix) + config = load_rate_limit_config(json_path) print(f"From JSON: limit={config.limit}, window={config.window_size}s") print(f"Algorithm: {config.algorithm.value}") @@ -346,7 +345,9 @@ def create_app_with_config() -> FastAPI: ) @app.exception_handler(RateLimitExceeded) - async def _rate_limit_handler(_: Request, exc: RateLimitExceeded) -> JSONResponse: + async def _rate_limit_handler( # pyright: ignore[reportUnusedFunction] + _: Request, exc: RateLimitExceeded + ) -> JSONResponse: return JSONResponse( status_code=429, content={ @@ -358,23 +359,26 @@ def create_app_with_config() -> FastAPI: @app.get("/") @rate_limit(limit=10, window_size=60) - async def _root(_: Request) -> dict[str, str]: + async def _root( # pyright: ignore[reportUnusedFunction] + _: Request, + ) -> dict[str, str]: return {"message": "Hello from config-loaded app!"} @app.get("/health") - async def _health() -> dict[str, str]: + async def _health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction] """Health check - exempt from rate limiting.""" return {"status": "healthy"} @app.get("/api/data") @rate_limit(limit=50, window_size=60) - async def _get_data(_: Request) -> dict[str, str]: + async def _get_data( # pyright: ignore[reportUnusedFunction] + _: Request, + ) -> dict[str, str]: return {"data": "Some API data"} return app -# Create the app instance app = create_app_with_config() @@ -383,59 +387,77 @@ app = create_app_with_config() # ============================================================================= +logger = logging.getLogger(__name__) + + def run_examples() -> None: """Run all configuration loading examples.""" - print("=" * 60) - print("FastAPI Traffic - Configuration Loader Examples") - print("=" * 60) + logging.basicConfig(level=logging.INFO, format="%(message)s") - print("\n1. Loading from environment variables:") - print("-" * 40) + logger.info("=" * 60) + logger.info("FastAPI Traffic - Configuration Loader Examples") + logger.info("=" * 60) + + logger.info("\n1. Loading from environment variables:") + logger.info("-" * 40) example_env_variables() - print("\n2. Loading GlobalConfig from environment:") - print("-" * 40) + logger.info("\n2. Loading GlobalConfig from environment:") + logger.info("-" * 40) example_global_config_env() - print("\n3. Loading from .env file:") - print("-" * 40) + logger.info("\n3. Loading from .env file:") + logger.info("-" * 40) example_dotenv_file() - print("\n4. Loading from JSON file:") - print("-" * 40) + logger.info("\n4. Loading from JSON file:") + logger.info("-" * 40) example_json_file() - print("\n5. Loading GlobalConfig from JSON:") - print("-" * 40) + logger.info("\n5. Loading GlobalConfig from JSON:") + logger.info("-" * 40) example_global_config_json() - print("\n6. Using custom environment prefix:") - print("-" * 40) + logger.info("\n6. Using custom environment prefix:") + logger.info("-" * 40) example_custom_prefix() - print("\n7. Validation and error handling:") - print("-" * 40) + logger.info("\n7. Validation and error handling:") + logger.info("-" * 40) example_validation() - print("\n8. Environment-based configuration:") - print("-" * 40) + logger.info("\n8. Environment-based configuration:") + logger.info("-" * 40) example_environment_based_config() - print("\n" + "=" * 60) - print("All examples completed!") - print("=" * 60) + logger.info("\n" + "=" * 60) + logger.info("All examples completed!") + logger.info("=" * 60) if __name__ == "__main__": - import sys + import argparse - if len(sys.argv) > 1 and sys.argv[1] == "--demo": - # Run the demo examples + import uvicorn + + parser = argparse.ArgumentParser(description="Config loader example") + parser.add_argument( + "--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)" + ) + parser.add_argument( + "--port", type=int, default=8011, help="Port to bind to (default: 8011)" + ) + parser.add_argument( + "--demo", + action="store_true", + help="Run configuration examples instead of server", + ) + args = parser.parse_args() + + if args.demo: run_examples() else: - # Run the FastAPI app - import uvicorn - - print("Starting FastAPI app with config loader...") - print("Run with --demo flag to see configuration examples") - uvicorn.run(app, host="127.0.0.1", port=8011) + logging.basicConfig(level=logging.INFO, format="%(message)s") + logger.info("Starting FastAPI app with config loader...") + logger.info("Run with --demo flag to see configuration examples") + uvicorn.run(app, host=args.host, port=args.port) diff --git a/examples/README.md b/examples/README.md index 1ffc44c..1fd8ca3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -160,11 +160,12 @@ Some examples support configuration via environment variables: Basic examples only need `fastapi-traffic` and `uvicorn`: ```bash -pip install fastapi-traffic uvicorn +uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git +uv add uvicorn ``` For Redis examples: ```bash -pip install redis +uv add redis ``` diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastapi_traffic/__init__.py b/fastapi_traffic/__init__.py index a640449..cddedb9 100644 --- a/fastapi_traffic/__init__.py +++ b/fastapi_traffic/__init__.py @@ -20,7 +20,7 @@ from fastapi_traffic.exceptions import ( RateLimitExceeded, ) -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ "Algorithm", "Backend", diff --git a/fastapi_traffic/backends/redis.py b/fastapi_traffic/backends/redis.py index 2d80c3d..ef83b4b 100644 --- a/fastapi_traffic/backends/redis.py +++ b/fastapi_traffic/backends/redis.py @@ -19,7 +19,7 @@ class RedisBackend(Backend): def __init__( self, - client: Redis[bytes], + client: Redis, *, key_prefix: str = "fastapi_traffic", ) -> None: @@ -57,7 +57,8 @@ class RedisBackend(Backend): 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) + client: Redis = Redis.from_url(url, **kwargs) # pyright: ignore[reportUnknownMemberType] # fmt: skip + # note: No type stubs for redis-py, so we ignore the type errors instance = cls(client, key_prefix=key_prefix) instance._owns_client = True return instance @@ -119,7 +120,11 @@ class RedisBackend(Backend): pattern = f"{self._key_prefix}:*" cursor: int = 0 while True: - cursor, keys = await self._client.scan(cursor, match=pattern, count=100) + cursor, keys = ( + await self._client.scan( # pyright: ignore[reportUnknownMemberType] + cursor, match=pattern, count=100 + ) + ) if keys: await self._client.delete(*keys) if cursor == 0: @@ -134,11 +139,7 @@ class RedisBackend(Backend): async def ping(self) -> bool: """Check if Redis is reachable.""" - try: - await self._client.ping() - return True - except Exception: - return False + return await self._client.ping() # pyright: ignore[reportUnknownMemberType, reportGeneralTypeIssues, reportUnknownVariableType, reportReturnType] # fmt: skip async def get_stats(self) -> dict[str, Any]: """Get statistics about the rate limit storage.""" @@ -147,12 +148,20 @@ class RedisBackend(Backend): cursor: int = 0 count = 0 while True: - cursor, keys = await self._client.scan(cursor, match=pattern, count=100) + cursor, keys = ( + await self._client.scan( # pyright: ignore[reportUnknownMemberType] + cursor, match=pattern, count=100 + ) + ) count += len(keys) if cursor == 0: break - info = await self._client.info("memory") + info: dict[str, Any] = ( + await self._client.info( # pyright: ignore[reportUnknownMemberType] + "memory" + ) + ) return { "total_keys": count, "used_memory": info.get("used_memory_human", "unknown"), diff --git a/fastapi_traffic/core/algorithms.py b/fastapi_traffic/core/algorithms.py index 822cab7..490fcb8 100644 --- a/fastapi_traffic/core/algorithms.py +++ b/fastapi_traffic/core/algorithms.py @@ -89,8 +89,7 @@ class TokenBucketAlgorithm(BaseAlgorithm): remaining=int(tokens), reset_at=now + self.window_size, window_size=self.window_size, - retry_after = (1 - tokens) / self.refill_rate - + retry_after=(1 - tokens) / self.refill_rate, ) tokens = float(state.get("tokens", self.burst_size)) diff --git a/fastapi_traffic/core/config.py b/fastapi_traffic/core/config.py index 22d425b..83f6616 100644 --- a/fastapi_traffic/core/config.py +++ b/fastapi_traffic/core/config.py @@ -77,6 +77,6 @@ class GlobalConfig: 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) + exempt_ips: set[str] = field(default_factory=set[str]) + exempt_paths: set[str] = field(default_factory=set[str]) headers_prefix: str = "X-RateLimit" diff --git a/fastapi_traffic/core/config_loader.py b/fastapi_traffic/core/config_loader.py index 5d2c225..c42650d 100644 --- a/fastapi_traffic/core/config_loader.py +++ b/fastapi_traffic/core/config_loader.py @@ -5,7 +5,7 @@ from __future__ import annotations import json import os from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast from pydantic import BaseModel, ConfigDict, ValidationError, field_validator @@ -300,6 +300,14 @@ class ConfigLoader: _check_non_loadable(raw_config) + # Merge loadable overrides before validation so required fields can be supplied + non_loadable_overrides: dict[str, Any] = {} + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS: + non_loadable_overrides[key] = value + elif key in _RATE_LIMIT_FIELDS: + raw_config[key] = value + try: schema = _RateLimitSchema(**raw_config) # type: ignore[arg-type] # Pydantic coerces str→typed values at runtime except ValidationError as e: @@ -307,10 +315,8 @@ class ConfigLoader: config_dict = schema.model_dump(exclude_defaults=True) - # Apply overrides - for key, value in overrides.items(): - if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELDS: - config_dict[key] = value + # Apply non-loadable overrides (callables, etc.) + config_dict.update(non_loadable_overrides) # Ensure required field 'limit' is present if "limit" not in config_dict: @@ -363,7 +369,15 @@ class ConfigLoader: msg = "JSON root must be an object" raise ConfigurationError(msg) - _check_non_loadable(raw_config) + _check_non_loadable(cast("dict[str, Any]", raw_config)) + + # Merge loadable overrides before validation so required fields can be supplied + non_loadable_overrides: dict[str, Any] = {} + for key, value in overrides.items(): + if key in _NON_LOADABLE_FIELDS: + non_loadable_overrides[key] = value + elif key in _RATE_LIMIT_FIELDS: + raw_config[key] = value try: schema = _RateLimitSchema(**raw_config) # type: ignore[arg-type] # Pydantic coerces str→typed values at runtime @@ -372,10 +386,8 @@ class ConfigLoader: config_dict = schema.model_dump(exclude_defaults=True) - # Apply overrides - for key, value in overrides.items(): - if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELDS: - config_dict[key] = value + # Apply non-loadable overrides (callables, etc.) + config_dict.update(non_loadable_overrides) # Ensure required field 'limit' is present if "limit" not in config_dict: @@ -404,9 +416,7 @@ class ConfigLoader: Raises: ConfigurationError: If configuration is invalid. """ - raw_config = self._extract_env_config( - "GLOBAL_", _GLOBAL_FIELDS, env_source - ) + raw_config = self._extract_env_config("GLOBAL_", _GLOBAL_FIELDS, env_source) _check_non_loadable(raw_config) @@ -472,10 +482,10 @@ class ConfigLoader: msg = "JSON root must be an object" raise ConfigurationError(msg) - _check_non_loadable(raw_config) + _check_non_loadable(cast("dict[str, Any]", raw_config)) try: - schema = _GlobalSchema(**raw_config) + schema = _GlobalSchema(**cast("dict[str, Any]", raw_config)) except ValidationError as e: raise ConfigurationError(_format_validation_error(e)) from e diff --git a/fastapi_traffic/core/decorator.py b/fastapi_traffic/core/decorator.py index a582858..1a4a3b4 100644 --- a/fastapi_traffic/core/decorator.py +++ b/fastapi_traffic/core/decorator.py @@ -51,6 +51,7 @@ def rate_limit( /, ) -> Callable[[F], F]: ... + def rate_limit( limit: int, window_size: float = 60.0, @@ -243,7 +244,12 @@ class RateLimitDependency: exempt_when=exempt_when, ) - async def __call__(self, request: Request) -> Any: + async def __call__( + self, + request: ( + Request | Any + ), # Actually Request, but using Any to avoid Pydantic schema issues + ) -> Any: """Check rate limit and return info.""" limiter = get_limiter() result = await limiter.hit(request, self._config) diff --git a/fastapi_traffic/core/models.py b/fastapi_traffic/core/models.py index c005a0e..bc561c3 100644 --- a/fastapi_traffic/core/models.py +++ b/fastapi_traffic/core/models.py @@ -60,7 +60,7 @@ class TokenBucketState: class SlidingWindowState: """State for sliding window algorithm.""" - timestamps: list[float] = field(default_factory=list) + timestamps: list[float] = field(default_factory=list[float]) count: int = 0 diff --git a/pyproject.toml b/pyproject.toml index a13939d..c5289b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,20 @@ [project] name = "fastapi-traffic" -version = "0.2.0" +version = "0.3.0" description = "Production-grade rate limiting for FastAPI with multiple algorithms and backends" readme = "README.md" requires-python = ">=3.10" license = { text = "Apache-2.0" } -authors = [{ name = "zanewalker", email="bereckobrian@gmail.com" }] -keywords = ["fastapi", "rate-limit", "rate-limiting", "throttle", "api", "redis", "sqlite"] +authors = [{ name = "zanewalker", email = "bereckobrian@gmail.com" }] +keywords = [ + "fastapi", + "rate-limit", + "rate-limiting", + "throttle", + "api", + "redis", + "sqlite", +] classifiers = [ "Development Status :: 4 - Beta", "Framework :: FastAPI", @@ -21,10 +29,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = [ - "pydantic>=2.0", - "starlette>=0.27.0", -] +dependencies = ["pydantic>=2.0", "starlette>=0.27.0"] [project.optional-dependencies] redis = ["redis>=5.0.0"] @@ -41,6 +46,13 @@ dev = [ "fastapi>=0.100.0", "uvicorn>=0.29.0", ] +docs = [ + "sphinx>=7.0.0", + "furo>=2024.0.0", + "sphinx-copybutton>=0.5.0", + "myst-parser>=2.0.0", + "sphinx-design>=0.5.0", +] [project.urls] Documentation = "https://gitlab.com/zanewalker/fastapi-traffic#readme" @@ -71,23 +83,23 @@ 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 + "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 + "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] @@ -103,17 +115,21 @@ known-first-party = ["fastapi_traffic"] pythonVersion = "3.10" typeCheckingMode = "strict" reportMissingTypeStubs = false -reportUnknownMemberType = false -reportUnknownArgumentType = false -reportUnknownVariableType = false -reportUnknownParameterType = false +reportUnknownMemberType = true +reportUnknownArgumentType = true +reportUnknownVariableType = true +reportUnknownParameterType = true reportMissingImports = false -reportUnusedFunction = false +reportUnusedFunction = true reportInvalidTypeArguments = true reportGeneralTypeIssues = true +[[tool.pyright.executionEnvironments]] +root = "tests" +reportUnusedFunction = false + [tool.pytest.ini_options] -asyncio_mode = "auto" +asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] addopts = "-v --tb=short" @@ -126,4 +142,6 @@ dev = [ "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "uvicorn>=0.40.0", + "ruff>=0.9.9", + "pyright>=1.1.395", ] diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 1d4064f..03ac6b2 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -86,7 +86,9 @@ class TestConfigLoaderEnv: "FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0", } - with pytest.raises(ConfigurationError, match="Required field 'limit'"): + with pytest.raises( + ConfigurationError, match="Invalid value for 'limit': Field required" + ): loader.load_rate_limit_config_from_env(env_vars) def test_load_rate_limit_config_from_env_with_overrides( @@ -351,7 +353,9 @@ class TestConfigLoaderJson: config_data = {"window_size": 60.0} json_file.write_text(json.dumps(config_data)) - with pytest.raises(ConfigurationError, match="Required field 'limit'"): + with pytest.raises( + ConfigurationError, match="Invalid value for 'limit': Field required" + ): loader.load_rate_limit_config_from_json(json_file) diff --git a/uv.lock b/uv.lock index a419b82..47e10e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,32 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] [[package]] name = "annotated-doc" @@ -43,6 +69,15 @@ 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 = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -52,6 +87,19 @@ 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 = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "black" version = "25.12.0" @@ -105,6 +153,95 @@ 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 = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -230,12 +367,37 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] 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 = [ @@ -259,7 +421,7 @@ wheels = [ [[package]] name = "fastapi-traffic" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -282,6 +444,17 @@ dev = [ { name = "ruff" }, { name = "uvicorn" }, ] +docs = [ + { name = "furo" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-design", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-design", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] fastapi = [ { name = "fastapi" }, ] @@ -294,8 +467,10 @@ dev = [ { name = "black" }, { name = "fastapi" }, { name = "httpx" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, { name = "uvicorn" }, ] @@ -304,7 +479,9 @@ 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 = "furo", marker = "extra == 'docs'", specifier = ">=2024.0.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, @@ -314,21 +491,44 @@ requires-dist = [ { 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 = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0.0" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'", specifier = ">=0.5.0" }, + { name = "sphinx-design", marker = "extra == 'docs'", specifier = ">=0.5.0" }, { name = "starlette", specifier = ">=0.27.0" }, { name = "uvicorn", marker = "extra == 'dev'", specifier = ">=0.29.0" }, ] -provides-extras = ["redis", "fastapi", "all", "dev"] +provides-extras = ["redis", "fastapi", "all", "dev", "docs"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=25.12.0" }, { name = "fastapi", specifier = ">=0.128.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "pyright", specifier = ">=1.1.395" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "ruff", specifier = ">=0.9.9" }, { name = "uvicorn", specifier = ">=0.40.0" }, ] +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -375,6 +575,15 @@ 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 = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -384,6 +593,156 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -393,6 +752,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -648,6 +1049,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "redis" version = "7.1.0" @@ -660,6 +1125,30 @@ 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 = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "ruff" version = "0.14.11" @@ -686,6 +1175,231 @@ wheels = [ { 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 = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/43/65c0acbd8cc6f50195a3a1fc195c404988b15c67090e73c7a41a9f57d6bd/sphinx_design-0.6.1-py3-none-any.whl", hash = "sha256:b11f37db1a802a183d61b159d9a202314d4d2fe29c163437001324fe2f19549c", size = 2215338, upload-time = "2024-08-02T13:48:42.106Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "starlette" version = "0.50.0" @@ -769,6 +1483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0"