3 Commits

Author SHA1 Message Date
da9f0569b3 chore: bump version to 0.3.1 2026-03-19 21:38:29 +00:00
f65bb25bc4 docs: update version to 0.3.0 and sync changelog with CHANGELOG.md 2026-03-19 21:36:14 +00:00
f3453cb0fc release: bump version to 0.3.0
- Refactor Redis backend connection handling and pool management
- Update algorithm implementations with improved type annotations
- Enhance config loader validation with stricter Pydantic schemas
- Improve decorator and middleware error handling
- Expand example scripts with better docstrings and usage patterns
- Add new 00_basic_usage.py example for quick start
- Reorganize examples directory structure
- Fix type annotation inconsistencies across core modules
- Update dependencies in pyproject.toml
2026-03-17 21:04:34 +00:00
51 changed files with 6538 additions and 166 deletions

6
.gitignore vendored
View File

@@ -13,4 +13,8 @@ things-todo.md
.ruff_cache
.qodo
.pytest_cache
.vscode/
.vscode
docs/_build
docs/_static
docs/_templates

View File

@@ -5,9 +5,43 @@ 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.1] - 2026-03-19
### Changed
- Updated documentation version references to match release version
- Synchronized docs/changelog.rst with CHANGELOG.md
## [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 +62,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 +71,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)

View File

@@ -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

14
docs/Makefile Normal file
View File

@@ -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)

View File

@@ -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())

View File

@@ -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.

367
docs/advanced/testing.rst Normal file
View File

@@ -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()}")

211
docs/api/algorithms.rst Normal file
View File

@@ -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")

266
docs/api/backends.rst Normal file
View File

@@ -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).

245
docs/api/config.rst Normal file
View File

@@ -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

154
docs/api/decorator.rst Normal file
View File

@@ -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)

473
docs/api/dependency.rst Normal file
View File

@@ -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

165
docs/api/exceptions.rst Normal file
View File

@@ -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``.

118
docs/api/middleware.rst Normal file
View File

@@ -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,
)

115
docs/changelog.rst Normal file
View File

@@ -0,0 +1,115 @@
Changelog
=========
All notable changes to FastAPI Traffic are 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.1] - 2026-03-19
--------------------
Changed
^^^^^^^
- Updated documentation version references to match release version
- Synchronized docs/changelog.rst with CHANGELOG.md
[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
- 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)

103
docs/conf.py Normal file
View File

@@ -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.3.1"
version = "0.3.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

204
docs/contributing.rst Normal file
View File

@@ -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 <https://www.conventionalcommits.org/>`_:
- ``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!

View File

@@ -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.3.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.

View File

@@ -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

148
docs/index.rst Normal file
View File

@@ -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`

5
docs/requirements.txt Normal file
View File

@@ -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

View File

@@ -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"}

View File

@@ -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.

View File

@@ -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"}

View File

@@ -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="<h1>Slow down!</h1><p>Please wait a moment before trying again.</p>",
)
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.

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
```

0
examples/__init__.py Normal file
View File

View File

@@ -20,7 +20,7 @@ from fastapi_traffic.exceptions import (
RateLimitExceeded,
)
__version__ = "0.2.0"
__version__ = "0.3.1"
__all__ = [
"Algorithm",
"Backend",

View File

@@ -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"),

View File

@@ -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))

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -1,12 +1,20 @@
[project]
name = "fastapi-traffic"
version = "0.2.0"
version = "0.3.1"
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,15 +115,19 @@ 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_default_fixture_loop_scope = "function"
@@ -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",
]

View File

@@ -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)

729
uv.lock generated
View File

@@ -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"