Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da9f0569b3 | |||
| f65bb25bc4 | |||
| f3453cb0fc | |||
| 492410614f | |||
| 4f19c0b19e | |||
| fe07912040 | |||
| 6bdeab2b4e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,4 +13,8 @@ things-todo.md
|
|||||||
.ruff_cache
|
.ruff_cache
|
||||||
.qodo
|
.qodo
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
docs/_build
|
||||||
|
docs/_static
|
||||||
|
docs/_templates
|
||||||
@@ -95,18 +95,6 @@ build-package:
|
|||||||
- if: $CI_COMMIT_TAG
|
- if: $CI_COMMIT_TAG
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
# Publish to PyPI (only on tags)
|
|
||||||
publish-pypi:
|
|
||||||
extends: .python-base
|
|
||||||
stage: publish
|
|
||||||
script:
|
|
||||||
- uv publish --token $PYPI_TOKEN
|
|
||||||
rules:
|
|
||||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
|
||||||
when: manual
|
|
||||||
needs:
|
|
||||||
- build-package
|
|
||||||
|
|
||||||
# Publish to GitLab Package Registry
|
# Publish to GitLab Package Registry
|
||||||
publish-gitlab:
|
publish-gitlab:
|
||||||
extends: .python-base
|
extends: .python-base
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -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/),
|
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).
|
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
|
## [0.2.0] - 2026-02-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Configuration Loader** - Load rate limiting configuration from external files:
|
- **Configuration Loader** - Load rate limiting configuration from external files:
|
||||||
- `ConfigLoader` class for loading `RateLimitConfig` and `GlobalConfig`
|
- `ConfigLoader` class for loading `RateLimitConfig` and `GlobalConfig`
|
||||||
- Support for `.env` files with `FASTAPI_TRAFFIC_*` prefixed variables
|
- 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
|
- `httpx` and `pytest-asyncio` as dev dependencies for testing
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved documentation in README.md and DEVELOPMENT.md
|
- Improved documentation in README.md and DEVELOPMENT.md
|
||||||
- Added `asyncio_default_fixture_loop_scope` config for pytest-asyncio compatibility
|
- 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.
|
Initial release.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Core rate limiting with `@rate_limit` decorator
|
- Core rate limiting with `@rate_limit` decorator
|
||||||
- Five algorithms: Token Bucket, Sliding Window, Fixed Window, Leaky Bucket, Sliding Window Counter
|
- Five algorithms: Token Bucket, Sliding Window, Fixed Window, Leaky Bucket, Sliding Window Counter
|
||||||
- Three storage backends: Memory (default), SQLite (persistent), Redis (distributed)
|
- Three storage backends: Memory (default), SQLite (persistent), Redis (distributed)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Want to contribute or just poke around? Here's how to get set up.
|
|||||||
### Using uv (the fast way)
|
### Using uv (the fast way)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitlab.com/bereckobrian/fastapi-traffic.git
|
git clone https://gitlab.com/zanewalker/fastapi-traffic.git
|
||||||
cd fastapi-traffic
|
cd fastapi-traffic
|
||||||
|
|
||||||
# This creates a venv and installs everything
|
# This creates a venv and installs everything
|
||||||
@@ -25,7 +25,7 @@ That's it. uv figures out the rest.
|
|||||||
### Using pip
|
### Using pip
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitlab.com/bereckobrian/fastapi-traffic.git
|
git clone https://gitlab.com/zanewalker/fastapi-traffic.git
|
||||||
cd fastapi-traffic
|
cd fastapi-traffic
|
||||||
|
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -18,26 +18,26 @@ Most rate limiting solutions are either too simple (fixed window only) or too co
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic installation (memory backend only)
|
# Basic installation (memory backend only)
|
||||||
pip install fastapi-traffic
|
pip install git+https://gitlab.com/zanewalker/fastapi-traffic.git
|
||||||
|
|
||||||
# With Redis support
|
# With Redis support
|
||||||
pip install fastapi-traffic[redis]
|
pip install git+https://gitlab.com/zanewalker/fastapi-traffic.git[redis]
|
||||||
|
|
||||||
# With all extras
|
# With all extras
|
||||||
pip install fastapi-traffic[all]
|
pip install git+https://gitlab.com/zanewalker/fastapi-traffic.git[all]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using uv
|
### Using uv
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic installation
|
# Basic installation
|
||||||
uv add fastapi-traffic
|
uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git
|
||||||
|
|
||||||
# With Redis support
|
# With Redis support
|
||||||
uv add fastapi-traffic[redis]
|
uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git[redis]
|
||||||
|
|
||||||
# With all extras
|
# With all extras
|
||||||
uv add fastapi-traffic[all]
|
uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git[all]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -188,16 +188,19 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
|||||||
## Backends
|
## Backends
|
||||||
|
|
||||||
### MemoryBackend (Default)
|
### MemoryBackend (Default)
|
||||||
|
|
||||||
- In-memory storage with LRU eviction
|
- In-memory storage with LRU eviction
|
||||||
- Best for single-process applications
|
- Best for single-process applications
|
||||||
- No persistence across restarts
|
- No persistence across restarts
|
||||||
|
|
||||||
### SQLiteBackend
|
### SQLiteBackend
|
||||||
|
|
||||||
- Persistent storage using SQLite
|
- Persistent storage using SQLite
|
||||||
- WAL mode for better performance
|
- WAL mode for better performance
|
||||||
- Suitable for single-node deployments
|
- Suitable for single-node deployments
|
||||||
|
|
||||||
### RedisBackend
|
### RedisBackend
|
||||||
|
|
||||||
- Distributed storage using Redis
|
- Distributed storage using Redis
|
||||||
- Required for multi-node deployments
|
- Required for multi-node deployments
|
||||||
- Supports atomic operations via Lua scripts
|
- Supports atomic operations via Lua scripts
|
||||||
|
|||||||
14
docs/Makefile
Normal file
14
docs/Makefile
Normal 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)
|
||||||
319
docs/advanced/distributed-systems.rst
Normal file
319
docs/advanced/distributed-systems.rst
Normal 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())
|
||||||
291
docs/advanced/performance.rst
Normal file
291
docs/advanced/performance.rst
Normal 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
367
docs/advanced/testing.rst
Normal 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
211
docs/api/algorithms.rst
Normal 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
266
docs/api/backends.rst
Normal 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
245
docs/api/config.rst
Normal 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
154
docs/api/decorator.rst
Normal 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
473
docs/api/dependency.rst
Normal 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
165
docs/api/exceptions.rst
Normal 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
118
docs/api/middleware.rst
Normal 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
115
docs/changelog.rst
Normal 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
103
docs/conf.py
Normal 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
204
docs/contributing.rst
Normal 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!
|
||||||
105
docs/getting-started/installation.rst
Normal file
105
docs/getting-started/installation.rst
Normal 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.
|
||||||
220
docs/getting-started/quickstart.rst
Normal file
220
docs/getting-started/quickstart.rst
Normal 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
148
docs/index.rst
Normal 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
5
docs/requirements.txt
Normal 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
|
||||||
290
docs/user-guide/algorithms.rst
Normal file
290
docs/user-guide/algorithms.rst
Normal 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"}
|
||||||
312
docs/user-guide/backends.rst
Normal file
312
docs/user-guide/backends.rst
Normal 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.
|
||||||
315
docs/user-guide/configuration.rst
Normal file
315
docs/user-guide/configuration.rst
Normal 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"}
|
||||||
277
docs/user-guide/exception-handling.rst
Normal file
277
docs/user-guide/exception-handling.rst
Normal 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.
|
||||||
258
docs/user-guide/key-extractors.rst
Normal file
258
docs/user-guide/key-extractors.rst
Normal 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,
|
||||||
|
)
|
||||||
322
docs/user-guide/middleware.rst
Normal file
322
docs/user-guide/middleware.rst
Normal 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
|
||||||
|
)
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Basic usage examples for fastapi-traffic."""
|
"""Basic usage examples for fastapi-traffic."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import TYPE_CHECKING
|
from typing import Annotated, TypeAlias
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -17,15 +16,19 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.decorator import RateLimitDependency
|
from fastapi_traffic.core.decorator import RateLimitDependency
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
from fastapi_traffic.core.models import RateLimitInfo
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
from collections.abc import AsyncIterator
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
# Configure global rate limiter with SQLite backend for persistence
|
# Configure global rate limiter with SQLite backend for persistence
|
||||||
backend = SQLiteBackend("rate_limits.db")
|
backend = SQLiteBackend("rate_limits.db")
|
||||||
limiter = RateLimiter(backend)
|
limiter = RateLimiter(backend)
|
||||||
set_limiter(limiter)
|
set_limiter(limiter)
|
||||||
|
|
||||||
|
basic_ratelimiter = RateLimitDependency(limit=20, window_size=60)
|
||||||
|
RateLimitDep: TypeAlias = Annotated[RateLimitInfo, Depends(basic_ratelimiter)]
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
@@ -108,19 +111,18 @@ async def api_key_endpoint(_: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
# Example 5: Using dependency injection
|
# Example 5: Using dependency injection
|
||||||
rate_limit_dep = RateLimitDependency(limit=20, window_size=60)
|
# Note: This dependency injection seems to be tripping pydantic, needs to be looked into.
|
||||||
|
"""@app.get("/api/dependency")
|
||||||
|
|
||||||
@app.get("/api/dependency")
|
|
||||||
async def dependency_endpoint(
|
async def dependency_endpoint(
|
||||||
_: Request,
|
_: Request,
|
||||||
rate_info: dict[str, object] = Depends(rate_limit_dep),
|
rate_info: RateLimitDep,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Endpoint using rate limit as dependency."""
|
'''Endpoint using rate limit as dependency.'''
|
||||||
return {
|
return {
|
||||||
"message": "Rate limit info available",
|
"message": "Rate limit info available",
|
||||||
"rate_limit": rate_info,
|
"rate_limit": rate_info,
|
||||||
}
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Example 6: Exempt certain requests
|
# Example 6: Exempt certain requests
|
||||||
@@ -163,12 +165,30 @@ async def expensive_endpoint(_: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@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)."""
|
"""Health check endpoint (typically exempt from rate limiting)."""
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
@@ -15,6 +15,9 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
# Step 1: Create a backend and limiter
|
# Step 1: Create a backend and limiter
|
||||||
backend = MemoryBackend()
|
backend = MemoryBackend()
|
||||||
limiter = RateLimiter(backend)
|
limiter = RateLimiter(backend)
|
||||||
@@ -55,6 +58,24 @@ async def get_data(_: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
backend = MemoryBackend()
|
backend = MemoryBackend()
|
||||||
limiter = RateLimiter(backend)
|
limiter = RateLimiter(backend)
|
||||||
|
|
||||||
@@ -126,6 +129,22 @@ async def leaky_bucket(_: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
|
|
||||||
# Choose backend based on environment
|
# Choose backend based on environment
|
||||||
def get_backend():
|
def get_backend():
|
||||||
@@ -100,10 +103,26 @@ async def backend_info() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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:
|
# Run with different backends:
|
||||||
# RATE_LIMIT_BACKEND=memory python 03_backends.py
|
# RATE_LIMIT_BACKEND=memory python 03_backends.py
|
||||||
# RATE_LIMIT_BACKEND=sqlite 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
|
# 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)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
backend = MemoryBackend()
|
backend = MemoryBackend()
|
||||||
limiter = RateLimiter(backend)
|
limiter = RateLimiter(backend)
|
||||||
|
|
||||||
@@ -151,6 +154,22 @@ async def user_action(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from fastapi_traffic.middleware import RateLimitMiddleware
|
|||||||
# from fastapi_traffic.middleware import SlidingWindowMiddleware
|
# from fastapi_traffic.middleware import SlidingWindowMiddleware
|
||||||
# from fastapi_traffic.middleware import TokenBucketMiddleware
|
# from fastapi_traffic.middleware import TokenBucketMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8001
|
||||||
|
|
||||||
app = FastAPI(title="Middleware Rate Limiting")
|
app = FastAPI(title="Middleware Rate Limiting")
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +108,22 @@ async def docs_info() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Annotated, Any, TypeAlias
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -15,6 +15,10 @@ from fastapi_traffic import (
|
|||||||
)
|
)
|
||||||
from fastapi_traffic.core.decorator import RateLimitDependency
|
from fastapi_traffic.core.decorator import RateLimitDependency
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
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()
|
backend = MemoryBackend()
|
||||||
limiter = RateLimiter(backend)
|
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
|
# 1. Basic dependency - rate limit info available in endpoint
|
||||||
basic_rate_limit = RateLimitDependency(limit=10, window_size=60)
|
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(
|
free_tier_limit = RateLimitDependency(
|
||||||
limit=10,
|
limit=10,
|
||||||
window_size=60,
|
window_size=60,
|
||||||
@@ -84,11 +65,38 @@ enterprise_tier_limit = RateLimitDependency(
|
|||||||
key_prefix="enterprise",
|
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(
|
async def tiered_rate_limit(
|
||||||
request: Request,
|
request: Request,
|
||||||
tier: str = Depends(get_user_tier),
|
tier: TierDep,
|
||||||
) -> Any:
|
) -> RateLimitInfo:
|
||||||
"""Apply different rate limits based on user tier."""
|
"""Apply different rate limits based on user tier."""
|
||||||
if tier == "enterprise":
|
if tier == "enterprise":
|
||||||
return await enterprise_tier_limit(request)
|
return await enterprise_tier_limit(request)
|
||||||
@@ -98,10 +106,13 @@ async def tiered_rate_limit(
|
|||||||
return await free_tier_limit(request)
|
return await free_tier_limit(request)
|
||||||
|
|
||||||
|
|
||||||
|
TieredRateLimit: TypeAlias = Annotated[RateLimitInfo, Depends(tiered_rate_limit)]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/tiered")
|
@app.get("/tiered")
|
||||||
async def tiered_endpoint(
|
async def tiered_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
rate_info: Any = Depends(tiered_rate_limit),
|
rate_info: TieredRateLimit,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Endpoint with tier-based rate limiting."""
|
"""Endpoint with tier-based rate limiting."""
|
||||||
tier = get_user_tier(request)
|
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")
|
@app.get("/api/resource")
|
||||||
async def api_resource(
|
async def api_resource(
|
||||||
_: Request,
|
_: Request,
|
||||||
rate_info: Any = Depends(api_rate_limit),
|
rate_info: ApiRateLimit,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""API endpoint with per-API-key rate limiting."""
|
"""API endpoint with per-API-key rate limiting."""
|
||||||
return {
|
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(
|
async def combined_rate_limit(
|
||||||
_: Request,
|
_: Request,
|
||||||
minute_info: Any = Depends(per_minute_limit),
|
minute_info: PerMinuteLimit,
|
||||||
hour_info: Any = Depends(per_hour_limit),
|
hour_info: PerHourLimit,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Apply both per-minute and per-hour limits."""
|
"""Apply both per-minute and per-hour limits."""
|
||||||
return {
|
return {
|
||||||
@@ -173,10 +191,13 @@ async def combined_rate_limit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CombinedRateLimit: TypeAlias = Annotated[dict[str, Any], Depends(combined_rate_limit)]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/combined")
|
@app.get("/combined")
|
||||||
async def combined_endpoint(
|
async def combined_endpoint(
|
||||||
_: Request,
|
_: Request,
|
||||||
rate_info: dict[str, Any] = Depends(combined_rate_limit),
|
rate_info: CombinedRateLimit,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Endpoint with multiple rate limit tiers."""
|
"""Endpoint with multiple rate limit tiers."""
|
||||||
return {
|
return {
|
||||||
@@ -199,10 +220,15 @@ internal_exempt_limit = RateLimitDependency(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
InternalExemptLimit: TypeAlias = Annotated[
|
||||||
|
RateLimitInfo, Depends(internal_exempt_limit)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/internal-exempt")
|
@app.get("/internal-exempt")
|
||||||
async def internal_exempt_endpoint(
|
async def internal_exempt_endpoint(
|
||||||
request: Request,
|
request: Request,
|
||||||
rate_info: Any = Depends(internal_exempt_limit),
|
rate_info: InternalExemptLimit,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Internal requests are exempt from rate limiting."""
|
"""Internal requests are exempt from rate limiting."""
|
||||||
is_internal = is_internal_request(request)
|
is_internal = is_internal_request(request)
|
||||||
@@ -220,6 +246,22 @@ async def internal_exempt_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -29,13 +29,18 @@ from fastapi_traffic import (
|
|||||||
from fastapi_traffic.backends.redis import RedisBackend
|
from fastapi_traffic.backends.redis import RedisBackend
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8001
|
||||||
|
|
||||||
|
|
||||||
async def create_redis_backend():
|
async def create_redis_backend():
|
||||||
"""Create Redis backend with fallback to memory."""
|
"""Create Redis backend with fallback to memory."""
|
||||||
try:
|
try:
|
||||||
from fastapi_traffic import RedisBackend
|
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(
|
backend = await RedisBackend.from_url(
|
||||||
redis_url,
|
redis_url,
|
||||||
key_prefix="myapp",
|
key_prefix="myapp",
|
||||||
@@ -188,10 +193,28 @@ async def stats(backend: BackendDep) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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:
|
# Run multiple instances on different ports to test distributed limiting:
|
||||||
# REDIS_URL=redis://localhost:6379/0 python 07_redis_distributed.py
|
# REDIS_URL=redis://localhost:6379/0 python 07_redis_distributed.py
|
||||||
# In another terminal:
|
# In another terminal:
|
||||||
# uvicorn 07_redis_distributed:app --port 8001
|
# uvicorn 07_redis_distributed:app --port 8002
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from fastapi_traffic import (
|
|||||||
from fastapi_traffic.core.decorator import RateLimitDependency
|
from fastapi_traffic.core.decorator import RateLimitDependency
|
||||||
from fastapi_traffic.core.limiter import set_limiter
|
from fastapi_traffic.core.limiter import set_limiter
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
backend = MemoryBackend()
|
backend = MemoryBackend()
|
||||||
limiter = RateLimiter(backend)
|
limiter = RateLimiter(backend)
|
||||||
|
|
||||||
@@ -261,9 +264,25 @@ async def pricing() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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:
|
# Test with different API keys:
|
||||||
# curl -H "X-API-Key: free-key-123" http://localhost:8000/api/v1/data
|
# 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
|
# 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)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ async def lifespan(_: FastAPI):
|
|||||||
await limiter.close()
|
await limiter.close()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
app = FastAPI(title="Custom Responses Example", lifespan=lifespan)
|
app = FastAPI(title="Custom Responses Example", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +214,22 @@ async def graceful_endpoint(_: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ async def lifespan(_: FastAPI):
|
|||||||
await limiter.close()
|
await limiter.close()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8000
|
||||||
|
|
||||||
app = FastAPI(title="Advanced Patterns", lifespan=lifespan)
|
app = FastAPI(title="Advanced Patterns", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
@@ -327,6 +330,22 @@ async def cascading_endpoint(
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ making it easy to manage settings across different environments (dev, staging, p
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import asynccontextmanager
|
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_ALGORITHM=sliding_window_counter
|
||||||
export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapi
|
export FASTAPI_TRAFFIC_RATE_LIMIT_KEY_PREFIX=myapi
|
||||||
"""
|
"""
|
||||||
# Using the convenience function
|
|
||||||
config = load_rate_limit_config_from_env(
|
config = load_rate_limit_config_from_env(
|
||||||
# You can provide overrides for values not in env vars
|
# You can provide overrides for values not in env vars
|
||||||
limit=50, # Default if FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT not set
|
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_INCLUDE_HEADERS=true
|
||||||
FASTAPI_TRAFFIC_RATE_LIMIT_ERROR_MESSAGE="Too many requests, please slow down"
|
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:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
|
||||||
f.write("# Rate limit configuration\n")
|
f.write("# Rate limit configuration\n")
|
||||||
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100\n")
|
f.write("FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT=100\n")
|
||||||
@@ -104,7 +104,7 @@ def example_dotenv_file() -> RateLimitConfig:
|
|||||||
env_path = f.name
|
env_path = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load using auto-detection (detects .env suffix)
|
|
||||||
config = load_rate_limit_config(env_path)
|
config = load_rate_limit_config(env_path)
|
||||||
print(f"From .env: limit={config.limit}, algorithm={config.algorithm}")
|
print(f"From .env: limit={config.limit}, algorithm={config.algorithm}")
|
||||||
print(f"Burst size: {config.burst_size}")
|
print(f"Burst size: {config.burst_size}")
|
||||||
@@ -132,7 +132,6 @@ def example_json_file() -> RateLimitConfig:
|
|||||||
"cost": 1
|
"cost": 1
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Create a sample JSON file for demonstration
|
|
||||||
config_data = {
|
config_data = {
|
||||||
"limit": 500,
|
"limit": 500,
|
||||||
"window_size": 300.0,
|
"window_size": 300.0,
|
||||||
@@ -148,7 +147,7 @@ def example_json_file() -> RateLimitConfig:
|
|||||||
json_path = f.name
|
json_path = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load using auto-detection (detects .json suffix)
|
|
||||||
config = load_rate_limit_config(json_path)
|
config = load_rate_limit_config(json_path)
|
||||||
print(f"From JSON: limit={config.limit}, window={config.window_size}s")
|
print(f"From JSON: limit={config.limit}, window={config.window_size}s")
|
||||||
print(f"Algorithm: {config.algorithm.value}")
|
print(f"Algorithm: {config.algorithm.value}")
|
||||||
@@ -346,7 +345,9 @@ def create_app_with_config() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(RateLimitExceeded)
|
@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(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={
|
content={
|
||||||
@@ -358,23 +359,26 @@ def create_app_with_config() -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@rate_limit(limit=10, window_size=60)
|
@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!"}
|
return {"message": "Hello from config-loaded app!"}
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def _health() -> dict[str, str]:
|
async def _health() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
|
||||||
"""Health check - exempt from rate limiting."""
|
"""Health check - exempt from rate limiting."""
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
@app.get("/api/data")
|
@app.get("/api/data")
|
||||||
@rate_limit(limit=50, window_size=60)
|
@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 {"data": "Some API data"}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
# Create the app instance
|
|
||||||
app = create_app_with_config()
|
app = create_app_with_config()
|
||||||
|
|
||||||
|
|
||||||
@@ -383,59 +387,77 @@ app = create_app_with_config()
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def run_examples() -> None:
|
def run_examples() -> None:
|
||||||
"""Run all configuration loading examples."""
|
"""Run all configuration loading examples."""
|
||||||
print("=" * 60)
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
print("FastAPI Traffic - Configuration Loader Examples")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print("\n1. Loading from environment variables:")
|
logger.info("=" * 60)
|
||||||
print("-" * 40)
|
logger.info("FastAPI Traffic - Configuration Loader Examples")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
logger.info("\n1. Loading from environment variables:")
|
||||||
|
logger.info("-" * 40)
|
||||||
example_env_variables()
|
example_env_variables()
|
||||||
|
|
||||||
print("\n2. Loading GlobalConfig from environment:")
|
logger.info("\n2. Loading GlobalConfig from environment:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_global_config_env()
|
example_global_config_env()
|
||||||
|
|
||||||
print("\n3. Loading from .env file:")
|
logger.info("\n3. Loading from .env file:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_dotenv_file()
|
example_dotenv_file()
|
||||||
|
|
||||||
print("\n4. Loading from JSON file:")
|
logger.info("\n4. Loading from JSON file:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_json_file()
|
example_json_file()
|
||||||
|
|
||||||
print("\n5. Loading GlobalConfig from JSON:")
|
logger.info("\n5. Loading GlobalConfig from JSON:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_global_config_json()
|
example_global_config_json()
|
||||||
|
|
||||||
print("\n6. Using custom environment prefix:")
|
logger.info("\n6. Using custom environment prefix:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_custom_prefix()
|
example_custom_prefix()
|
||||||
|
|
||||||
print("\n7. Validation and error handling:")
|
logger.info("\n7. Validation and error handling:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_validation()
|
example_validation()
|
||||||
|
|
||||||
print("\n8. Environment-based configuration:")
|
logger.info("\n8. Environment-based configuration:")
|
||||||
print("-" * 40)
|
logger.info("-" * 40)
|
||||||
example_environment_based_config()
|
example_environment_based_config()
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
logger.info("\n" + "=" * 60)
|
||||||
print("All examples completed!")
|
logger.info("All examples completed!")
|
||||||
print("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import argparse
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--demo":
|
import uvicorn
|
||||||
# Run the demo examples
|
|
||||||
|
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()
|
run_examples()
|
||||||
else:
|
else:
|
||||||
# Run the FastAPI app
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
import uvicorn
|
logger.info("Starting FastAPI app with config loader...")
|
||||||
|
logger.info("Run with --demo flag to see configuration examples")
|
||||||
print("Starting FastAPI app with config loader...")
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
print("Run with --demo flag to see configuration examples")
|
|
||||||
uvicorn.run(app, host="127.0.0.1", port=8011)
|
|
||||||
|
|||||||
@@ -160,11 +160,12 @@ Some examples support configuration via environment variables:
|
|||||||
Basic examples only need `fastapi-traffic` and `uvicorn`:
|
Basic examples only need `fastapi-traffic` and `uvicorn`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install fastapi-traffic uvicorn
|
uv add git+https://gitlab.com/zanewalker/fastapi-traffic.git
|
||||||
|
uv add uvicorn
|
||||||
```
|
```
|
||||||
|
|
||||||
For Redis examples:
|
For Redis examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install redis
|
uv add redis
|
||||||
```
|
```
|
||||||
|
|||||||
0
examples/__init__.py
Normal file
0
examples/__init__.py
Normal file
@@ -20,7 +20,7 @@ from fastapi_traffic.exceptions import (
|
|||||||
RateLimitExceeded,
|
RateLimitExceeded,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.3.1"
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Algorithm",
|
"Algorithm",
|
||||||
"Backend",
|
"Backend",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class RedisBackend(Backend):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: Redis[bytes],
|
client: Redis,
|
||||||
*,
|
*,
|
||||||
key_prefix: str = "fastapi_traffic",
|
key_prefix: str = "fastapi_traffic",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -57,7 +57,8 @@ class RedisBackend(Backend):
|
|||||||
msg = "redis package is required for RedisBackend. Install with: pip install redis"
|
msg = "redis package is required for RedisBackend. Install with: pip install redis"
|
||||||
raise ImportError(msg) from e
|
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 = cls(client, key_prefix=key_prefix)
|
||||||
instance._owns_client = True
|
instance._owns_client = True
|
||||||
return instance
|
return instance
|
||||||
@@ -119,7 +120,11 @@ class RedisBackend(Backend):
|
|||||||
pattern = f"{self._key_prefix}:*"
|
pattern = f"{self._key_prefix}:*"
|
||||||
cursor: int = 0
|
cursor: int = 0
|
||||||
while True:
|
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:
|
if keys:
|
||||||
await self._client.delete(*keys)
|
await self._client.delete(*keys)
|
||||||
if cursor == 0:
|
if cursor == 0:
|
||||||
@@ -134,11 +139,7 @@ class RedisBackend(Backend):
|
|||||||
|
|
||||||
async def ping(self) -> bool:
|
async def ping(self) -> bool:
|
||||||
"""Check if Redis is reachable."""
|
"""Check if Redis is reachable."""
|
||||||
try:
|
return await self._client.ping() # pyright: ignore[reportUnknownMemberType, reportGeneralTypeIssues, reportUnknownVariableType, reportReturnType] # fmt: skip
|
||||||
await self._client.ping()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_stats(self) -> dict[str, Any]:
|
async def get_stats(self) -> dict[str, Any]:
|
||||||
"""Get statistics about the rate limit storage."""
|
"""Get statistics about the rate limit storage."""
|
||||||
@@ -147,12 +148,20 @@ class RedisBackend(Backend):
|
|||||||
cursor: int = 0
|
cursor: int = 0
|
||||||
count = 0
|
count = 0
|
||||||
while True:
|
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)
|
count += len(keys)
|
||||||
if cursor == 0:
|
if cursor == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
info = await self._client.info("memory")
|
info: dict[str, Any] = (
|
||||||
|
await self._client.info( # pyright: ignore[reportUnknownMemberType]
|
||||||
|
"memory"
|
||||||
|
)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"total_keys": count,
|
"total_keys": count,
|
||||||
"used_memory": info.get("used_memory_human", "unknown"),
|
"used_memory": info.get("used_memory_human", "unknown"),
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class TokenBucketAlgorithm(BaseAlgorithm):
|
|||||||
remaining=int(tokens),
|
remaining=int(tokens),
|
||||||
reset_at=now + self.window_size,
|
reset_at=now + self.window_size,
|
||||||
window_size=self.window_size,
|
window_size=self.window_size,
|
||||||
|
retry_after=(1 - tokens) / self.refill_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
tokens = float(state.get("tokens", self.burst_size))
|
tokens = float(state.get("tokens", self.burst_size))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, TypeAlias
|
||||||
|
|
||||||
from fastapi_traffic.core.algorithms import Algorithm
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
|
|||||||
from fastapi_traffic.backends.base import Backend
|
from fastapi_traffic.backends.base import Backend
|
||||||
|
|
||||||
|
|
||||||
KeyExtractor = Callable[["Request"], str]
|
KeyExtractor: TypeAlias = Callable[["Request"], str]
|
||||||
|
|
||||||
|
|
||||||
def default_key_extractor(request: Request) -> str:
|
def default_key_extractor(request: Request) -> str:
|
||||||
@@ -55,10 +55,10 @@ class RateLimitConfig:
|
|||||||
if self.limit <= 0:
|
if self.limit <= 0:
|
||||||
msg = "limit must be positive"
|
msg = "limit must be positive"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if self.window_size <= 0:
|
elif self.window_size <= 0:
|
||||||
msg = "window_size must be positive"
|
msg = "window_size must be positive"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
if self.cost <= 0:
|
elif self.cost <= 0:
|
||||||
msg = "cost must be positive"
|
msg = "cost must be positive"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
@@ -77,6 +77,6 @@ class GlobalConfig:
|
|||||||
error_message: str = "Rate limit exceeded. Please try again later."
|
error_message: str = "Rate limit exceeded. Please try again later."
|
||||||
status_code: int = 429
|
status_code: int = 429
|
||||||
skip_on_error: bool = False
|
skip_on_error: bool = False
|
||||||
exempt_ips: set[str] = field(default_factory=set)
|
exempt_ips: set[str] = field(default_factory=set[str])
|
||||||
exempt_paths: set[str] = field(default_factory=set)
|
exempt_paths: set[str] = field(default_factory=set[str])
|
||||||
headers_prefix: str = "X-RateLimit"
|
headers_prefix: str = "X-RateLimit"
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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
|
||||||
|
|
||||||
from fastapi_traffic.core.algorithms import Algorithm
|
from fastapi_traffic.core.algorithms import Algorithm
|
||||||
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
from fastapi_traffic.core.config import GlobalConfig, RateLimitConfig
|
||||||
@@ -19,35 +21,6 @@ T = TypeVar("T", RateLimitConfig, GlobalConfig)
|
|||||||
# Environment variable prefix for config values
|
# Environment variable prefix for config values
|
||||||
ENV_PREFIX = "FASTAPI_TRAFFIC_"
|
ENV_PREFIX = "FASTAPI_TRAFFIC_"
|
||||||
|
|
||||||
# Mapping of config field names to their types for validation
|
|
||||||
_RATE_LIMIT_FIELD_TYPES: dict[str, type[Any]] = {
|
|
||||||
"limit": int,
|
|
||||||
"window_size": float,
|
|
||||||
"algorithm": Algorithm,
|
|
||||||
"key_prefix": str,
|
|
||||||
"burst_size": int,
|
|
||||||
"include_headers": bool,
|
|
||||||
"error_message": str,
|
|
||||||
"status_code": int,
|
|
||||||
"skip_on_error": bool,
|
|
||||||
"cost": int,
|
|
||||||
}
|
|
||||||
|
|
||||||
_GLOBAL_FIELD_TYPES: dict[str, type[Any]] = {
|
|
||||||
"enabled": bool,
|
|
||||||
"default_limit": int,
|
|
||||||
"default_window_size": float,
|
|
||||||
"default_algorithm": Algorithm,
|
|
||||||
"key_prefix": str,
|
|
||||||
"include_headers": bool,
|
|
||||||
"error_message": str,
|
|
||||||
"status_code": int,
|
|
||||||
"skip_on_error": bool,
|
|
||||||
"exempt_ips": set,
|
|
||||||
"exempt_paths": set,
|
|
||||||
"headers_prefix": str,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fields that cannot be loaded from config files (callables, complex objects)
|
# Fields that cannot be loaded from config files (callables, complex objects)
|
||||||
_NON_LOADABLE_FIELDS: frozenset[str] = frozenset(
|
_NON_LOADABLE_FIELDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
@@ -59,6 +32,98 @@ _NON_LOADABLE_FIELDS: frozenset[str] = frozenset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _RateLimitSchema(BaseModel):
|
||||||
|
"""Pydantic schema for validating rate limit configuration input."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
limit: int
|
||||||
|
window_size: float = 60.0
|
||||||
|
algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER
|
||||||
|
key_prefix: str = "ratelimit"
|
||||||
|
burst_size: int | None = None
|
||||||
|
include_headers: bool = True
|
||||||
|
error_message: str = "Rate limit exceeded"
|
||||||
|
status_code: int = 429
|
||||||
|
skip_on_error: bool = False
|
||||||
|
cost: int = 1
|
||||||
|
|
||||||
|
@field_validator("algorithm", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_algorithm(cls, v: Any) -> Any:
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class _GlobalSchema(BaseModel):
|
||||||
|
"""Pydantic schema for validating global configuration input."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
default_limit: int = 100
|
||||||
|
default_window_size: float = 60.0
|
||||||
|
default_algorithm: Algorithm = Algorithm.SLIDING_WINDOW_COUNTER
|
||||||
|
key_prefix: str = "fastapi_traffic"
|
||||||
|
include_headers: bool = True
|
||||||
|
error_message: str = "Rate limit exceeded. Please try again later."
|
||||||
|
status_code: int = 429
|
||||||
|
skip_on_error: bool = False
|
||||||
|
exempt_ips: set[str] = set()
|
||||||
|
exempt_paths: set[str] = set()
|
||||||
|
headers_prefix: str = "X-RateLimit"
|
||||||
|
|
||||||
|
@field_validator("default_algorithm", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_algorithm(cls, v: Any) -> Any:
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# Known field names per schema (used for env-var extraction)
|
||||||
|
_RATE_LIMIT_FIELDS: frozenset[str] = frozenset(_RateLimitSchema.model_fields.keys())
|
||||||
|
_GLOBAL_FIELDS: frozenset[str] = frozenset(_GlobalSchema.model_fields.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _check_non_loadable(data: Mapping[str, Any]) -> None:
|
||||||
|
"""Raise ConfigurationError if data contains non-loadable fields."""
|
||||||
|
for key in data:
|
||||||
|
if key in _NON_LOADABLE_FIELDS:
|
||||||
|
msg = f"Field '{key}' cannot be loaded from configuration files"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_validation_error(exc: ValidationError) -> str:
|
||||||
|
"""Convert a Pydantic ValidationError to a user-friendly message."""
|
||||||
|
errors = exc.errors()
|
||||||
|
if not errors:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
err = errors[0]
|
||||||
|
loc = ".".join(str(p) for p in err["loc"]) if err["loc"] else "unknown"
|
||||||
|
err_type = err["type"]
|
||||||
|
msg = err["msg"]
|
||||||
|
ctx = err.get("ctx", {})
|
||||||
|
|
||||||
|
if err_type == "extra_forbidden":
|
||||||
|
return f"Unknown configuration field: '{loc}'"
|
||||||
|
|
||||||
|
if err_type in ("int_parsing", "float_parsing"):
|
||||||
|
input_val = ctx.get("error", err.get("input", ""))
|
||||||
|
return f"Cannot parse value '{input_val}' as {loc}: {msg}"
|
||||||
|
|
||||||
|
if err_type == "bool_parsing":
|
||||||
|
return f"Cannot parse value as bool for '{loc}': {msg}"
|
||||||
|
|
||||||
|
if "enum" in err_type or err_type == "value_error":
|
||||||
|
input_val = err.get("input", "")
|
||||||
|
return f"Cannot parse value '{input_val}' as {loc}: {msg}"
|
||||||
|
|
||||||
|
return f"Invalid value for '{loc}': {msg}"
|
||||||
|
|
||||||
|
|
||||||
class ConfigLoader:
|
class ConfigLoader:
|
||||||
"""Loader for rate limiting configuration from various sources.
|
"""Loader for rate limiting configuration from various sources.
|
||||||
|
|
||||||
@@ -83,88 +148,6 @@ class ConfigLoader:
|
|||||||
"""
|
"""
|
||||||
self._env_prefix = env_prefix
|
self._env_prefix = env_prefix
|
||||||
|
|
||||||
def _parse_value(self, value: str, target_type: type[Any]) -> Any:
|
|
||||||
"""Parse a string value to the target type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: The string value to parse.
|
|
||||||
target_type: The target type to convert to.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The parsed value.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If the value cannot be parsed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if target_type is bool:
|
|
||||||
return value.lower() in ("true", "1", "yes", "on")
|
|
||||||
if target_type is int:
|
|
||||||
return int(value)
|
|
||||||
if target_type is float:
|
|
||||||
return float(value)
|
|
||||||
if target_type is str:
|
|
||||||
return value
|
|
||||||
if target_type is Algorithm:
|
|
||||||
return Algorithm(value.lower())
|
|
||||||
if target_type is set:
|
|
||||||
# Parse comma-separated values
|
|
||||||
if not value.strip():
|
|
||||||
return set()
|
|
||||||
return {item.strip() for item in value.split(",") if item.strip()}
|
|
||||||
except (ValueError, KeyError) as e:
|
|
||||||
msg = f"Cannot parse value '{value}' as {target_type.__name__}: {e}"
|
|
||||||
raise ConfigurationError(msg) from e
|
|
||||||
|
|
||||||
msg = f"Unsupported type: {target_type}"
|
|
||||||
raise ConfigurationError(msg)
|
|
||||||
|
|
||||||
def _validate_and_convert(
|
|
||||||
self,
|
|
||||||
data: Mapping[str, Any],
|
|
||||||
field_types: dict[str, type[Any]],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Validate and convert configuration data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data: Raw configuration data.
|
|
||||||
field_types: Mapping of field names to their expected types.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Validated and converted configuration dictionary.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConfigurationError: If validation fails.
|
|
||||||
"""
|
|
||||||
result: dict[str, Any] = {}
|
|
||||||
|
|
||||||
for key, value in data.items():
|
|
||||||
if key in _NON_LOADABLE_FIELDS:
|
|
||||||
msg = f"Field '{key}' cannot be loaded from configuration files"
|
|
||||||
raise ConfigurationError(msg)
|
|
||||||
|
|
||||||
if key not in field_types:
|
|
||||||
msg = f"Unknown configuration field: '{key}'"
|
|
||||||
raise ConfigurationError(msg)
|
|
||||||
|
|
||||||
target_type = field_types[key]
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
result[key] = self._parse_value(value, target_type)
|
|
||||||
elif target_type is set and isinstance(value, list):
|
|
||||||
result[key] = set(value)
|
|
||||||
elif target_type is Algorithm and isinstance(value, str):
|
|
||||||
result[key] = Algorithm(value.lower())
|
|
||||||
elif isinstance(value, target_type):
|
|
||||||
result[key] = value
|
|
||||||
elif target_type is float and isinstance(value, int):
|
|
||||||
result[key] = float(value)
|
|
||||||
else:
|
|
||||||
msg = f"Invalid type for '{key}': expected {target_type.__name__}, got {type(value).__name__}"
|
|
||||||
raise ConfigurationError(msg)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _load_dotenv_file(self, file_path: Path) -> dict[str, str]:
|
def _load_dotenv_file(self, file_path: Path) -> dict[str, str]:
|
||||||
"""Load environment variables from a .env file.
|
"""Load environment variables from a .env file.
|
||||||
|
|
||||||
@@ -248,14 +231,14 @@ class ConfigLoader:
|
|||||||
def _extract_env_config(
|
def _extract_env_config(
|
||||||
self,
|
self,
|
||||||
prefix: str,
|
prefix: str,
|
||||||
field_types: dict[str, type[Any]],
|
known_fields: frozenset[str],
|
||||||
env_source: Mapping[str, str] | None = None,
|
env_source: Mapping[str, str] | None = None,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Extract configuration from environment variables.
|
"""Extract configuration from environment variables.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prefix: The prefix to look for (e.g., "RATE_LIMIT_" or "GLOBAL_").
|
prefix: The prefix to look for (e.g., "RATE_LIMIT_" or "GLOBAL_").
|
||||||
field_types: Mapping of field names to their expected types.
|
known_fields: Set of known field names.
|
||||||
env_source: Optional source of environment variables. Defaults to os.environ.
|
env_source: Optional source of environment variables. Defaults to os.environ.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -268,11 +251,29 @@ class ConfigLoader:
|
|||||||
for key, value in source.items():
|
for key, value in source.items():
|
||||||
if key.startswith(full_prefix):
|
if key.startswith(full_prefix):
|
||||||
field_name = key[len(full_prefix) :].lower()
|
field_name = key[len(full_prefix) :].lower()
|
||||||
if field_name in field_types:
|
if field_name in known_fields:
|
||||||
result[field_name] = value
|
result[field_name] = value
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _parse_set_from_string(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Pre-process comma-separated string values into lists for set fields.
|
||||||
|
|
||||||
|
This handles the env-var case where sets are represented as
|
||||||
|
comma-separated strings (e.g., "127.0.0.1, 10.0.0.1").
|
||||||
|
"""
|
||||||
|
result = dict(data)
|
||||||
|
for key in ("exempt_ips", "exempt_paths"):
|
||||||
|
if key in result and isinstance(result[key], str):
|
||||||
|
value = result[key].strip()
|
||||||
|
if not value:
|
||||||
|
result[key] = []
|
||||||
|
else:
|
||||||
|
result[key] = [
|
||||||
|
item.strip() for item in value.split(",") if item.strip()
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
def load_rate_limit_config_from_env(
|
def load_rate_limit_config_from_env(
|
||||||
self,
|
self,
|
||||||
env_source: Mapping[str, str] | None = None,
|
env_source: Mapping[str, str] | None = None,
|
||||||
@@ -294,14 +295,28 @@ class ConfigLoader:
|
|||||||
ConfigurationError: If configuration is invalid.
|
ConfigurationError: If configuration is invalid.
|
||||||
"""
|
"""
|
||||||
raw_config = self._extract_env_config(
|
raw_config = self._extract_env_config(
|
||||||
"RATE_LIMIT_", _RATE_LIMIT_FIELD_TYPES, env_source
|
"RATE_LIMIT_", _RATE_LIMIT_FIELDS, env_source
|
||||||
)
|
)
|
||||||
config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES)
|
|
||||||
|
|
||||||
# Apply overrides
|
_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():
|
for key, value in overrides.items():
|
||||||
if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES:
|
if key in _NON_LOADABLE_FIELDS:
|
||||||
config_dict[key] = value
|
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:
|
||||||
|
raise ConfigurationError(_format_validation_error(e)) from e
|
||||||
|
|
||||||
|
config_dict = schema.model_dump(exclude_defaults=True)
|
||||||
|
|
||||||
|
# Apply non-loadable overrides (callables, etc.)
|
||||||
|
config_dict.update(non_loadable_overrides)
|
||||||
|
|
||||||
# Ensure required field 'limit' is present
|
# Ensure required field 'limit' is present
|
||||||
if "limit" not in config_dict:
|
if "limit" not in config_dict:
|
||||||
@@ -353,12 +368,26 @@ class ConfigLoader:
|
|||||||
if not isinstance(raw_config, dict):
|
if not isinstance(raw_config, dict):
|
||||||
msg = "JSON root must be an object"
|
msg = "JSON root must be an object"
|
||||||
raise ConfigurationError(msg)
|
raise ConfigurationError(msg)
|
||||||
config_dict = self._validate_and_convert(raw_config, _RATE_LIMIT_FIELD_TYPES)
|
|
||||||
|
|
||||||
# Apply overrides
|
_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():
|
for key, value in overrides.items():
|
||||||
if key in _NON_LOADABLE_FIELDS or key in _RATE_LIMIT_FIELD_TYPES:
|
if key in _NON_LOADABLE_FIELDS:
|
||||||
config_dict[key] = value
|
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:
|
||||||
|
raise ConfigurationError(_format_validation_error(e)) from e
|
||||||
|
|
||||||
|
config_dict = schema.model_dump(exclude_defaults=True)
|
||||||
|
|
||||||
|
# Apply non-loadable overrides (callables, etc.)
|
||||||
|
config_dict.update(non_loadable_overrides)
|
||||||
|
|
||||||
# Ensure required field 'limit' is present
|
# Ensure required field 'limit' is present
|
||||||
if "limit" not in config_dict:
|
if "limit" not in config_dict:
|
||||||
@@ -387,14 +416,23 @@ class ConfigLoader:
|
|||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If configuration is invalid.
|
ConfigurationError: If configuration is invalid.
|
||||||
"""
|
"""
|
||||||
raw_config = self._extract_env_config(
|
raw_config = self._extract_env_config("GLOBAL_", _GLOBAL_FIELDS, env_source)
|
||||||
"GLOBAL_", _GLOBAL_FIELD_TYPES, env_source
|
|
||||||
)
|
_check_non_loadable(raw_config)
|
||||||
config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES)
|
|
||||||
|
# Pre-process comma-separated strings into lists for set fields
|
||||||
|
processed = self._parse_set_from_string(raw_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schema = _GlobalSchema(**processed) # type: ignore[arg-type] # Pydantic coerces str→typed values at runtime
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ConfigurationError(_format_validation_error(e)) from e
|
||||||
|
|
||||||
|
config_dict = schema.model_dump(exclude_defaults=True)
|
||||||
|
|
||||||
# Apply overrides
|
# Apply overrides
|
||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES:
|
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELDS:
|
||||||
config_dict[key] = value
|
config_dict[key] = value
|
||||||
|
|
||||||
return GlobalConfig(**config_dict)
|
return GlobalConfig(**config_dict)
|
||||||
@@ -439,11 +477,23 @@ class ConfigLoader:
|
|||||||
"""
|
"""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
raw_config = self._load_json_file(path)
|
raw_config = self._load_json_file(path)
|
||||||
config_dict = self._validate_and_convert(raw_config, _GLOBAL_FIELD_TYPES)
|
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
msg = "JSON root must be an object"
|
||||||
|
raise ConfigurationError(msg)
|
||||||
|
|
||||||
|
_check_non_loadable(cast("dict[str, Any]", raw_config))
|
||||||
|
|
||||||
|
try:
|
||||||
|
schema = _GlobalSchema(**cast("dict[str, Any]", raw_config))
|
||||||
|
except ValidationError as e:
|
||||||
|
raise ConfigurationError(_format_validation_error(e)) from e
|
||||||
|
|
||||||
|
config_dict = schema.model_dump(exclude_defaults=True)
|
||||||
|
|
||||||
# Apply overrides
|
# Apply overrides
|
||||||
for key, value in overrides.items():
|
for key, value in overrides.items():
|
||||||
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELD_TYPES:
|
if key in _NON_LOADABLE_FIELDS or key in _GLOBAL_FIELDS:
|
||||||
config_dict[key] = value
|
config_dict[key] = value
|
||||||
|
|
||||||
return GlobalConfig(**config_dict)
|
return GlobalConfig(**config_dict)
|
||||||
|
|||||||
@@ -139,9 +139,23 @@ def rate_limit(
|
|||||||
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
return asyncio.get_event_loop().run_until_complete(
|
async def _sync_rate_limit() -> Any:
|
||||||
async_wrapper(*args, **kwargs)
|
request = _extract_request(args, kwargs)
|
||||||
)
|
if request is None:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
limiter = get_limiter()
|
||||||
|
result = await limiter.hit(request, config)
|
||||||
|
|
||||||
|
response = func(*args, **kwargs)
|
||||||
|
|
||||||
|
if config.include_headers and hasattr(response, "headers"):
|
||||||
|
for key, value in result.info.to_headers().items():
|
||||||
|
response.headers[key] = value
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return asyncio.get_event_loop().run_until_complete(_sync_rate_limit())
|
||||||
|
|
||||||
if _is_coroutine_function(func):
|
if _is_coroutine_function(func):
|
||||||
return async_wrapper # type: ignore[return-value]
|
return async_wrapper # type: ignore[return-value]
|
||||||
@@ -230,7 +244,12 @@ class RateLimitDependency:
|
|||||||
exempt_when=exempt_when,
|
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."""
|
"""Check rate limit and return info."""
|
||||||
limiter = get_limiter()
|
limiter = get_limiter()
|
||||||
result = await limiter.hit(request, self._config)
|
result = await limiter.hit(request, self._config)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TokenBucketState:
|
|||||||
class SlidingWindowState:
|
class SlidingWindowState:
|
||||||
"""State for sliding window algorithm."""
|
"""State for sliding window algorithm."""
|
||||||
|
|
||||||
timestamps: list[float] = field(default_factory=list)
|
timestamps: list[float] = field(default_factory=list[float])
|
||||||
count: int = 0
|
count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
6
main.py
6
main.py
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from fastapi-traffic!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-traffic"
|
name = "fastapi-traffic"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
description = "Production-grade rate limiting for FastAPI with multiple algorithms and backends"
|
description = "Production-grade rate limiting for FastAPI with multiple algorithms and backends"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = { text = "Apache-2.0" }
|
license = { text = "Apache-2.0" }
|
||||||
authors = [{ name = "zanewalker", email="bereckobrian@gmail.com" }]
|
authors = [{ name = "zanewalker", email = "bereckobrian@gmail.com" }]
|
||||||
keywords = ["fastapi", "rate-limit", "rate-limiting", "throttle", "api", "redis", "sqlite"]
|
keywords = [
|
||||||
|
"fastapi",
|
||||||
|
"rate-limit",
|
||||||
|
"rate-limiting",
|
||||||
|
"throttle",
|
||||||
|
"api",
|
||||||
|
"redis",
|
||||||
|
"sqlite",
|
||||||
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
"Framework :: FastAPI",
|
"Framework :: FastAPI",
|
||||||
@@ -21,9 +29,7 @@ classifiers = [
|
|||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = ["pydantic>=2.0", "starlette>=0.27.0"]
|
||||||
"starlette>=0.27.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
redis = ["redis>=5.0.0"]
|
redis = ["redis>=5.0.0"]
|
||||||
@@ -40,11 +46,18 @@ dev = [
|
|||||||
"fastapi>=0.100.0",
|
"fastapi>=0.100.0",
|
||||||
"uvicorn>=0.29.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]
|
[project.urls]
|
||||||
Documentation = "https://gitlab.com/fastapi-traffic/fastapi-traffic#readme"
|
Documentation = "https://gitlab.com/zanewalker/fastapi-traffic#readme"
|
||||||
Repository = "https://github.com/fastapi-traffic/fastapi-traffic"
|
Repository = "https://gitlab.com/zanewalker/fastapi-traffic"
|
||||||
Issues = "https://gitlab.com/bereckobrian/fastapi-traffic/issues"
|
Issues = "https://gitlab.com/zanewalker/fastapi-traffic/issues"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -70,23 +83,23 @@ line-length = 88
|
|||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
"E", # pycodestyle errors
|
"E", # pycodestyle errors
|
||||||
"W", # pycodestyle warnings
|
"W", # pycodestyle warnings
|
||||||
"F", # Pyflakes
|
"F", # Pyflakes
|
||||||
"I", # isort
|
"I", # isort
|
||||||
"B", # flake8-bugbear
|
"B", # flake8-bugbear
|
||||||
"C4", # flake8-comprehensions
|
"C4", # flake8-comprehensions
|
||||||
"UP", # pyupgrade
|
"UP", # pyupgrade
|
||||||
"ARG", # flake8-unused-arguments
|
"ARG", # flake8-unused-arguments
|
||||||
"SIM", # flake8-simplify
|
"SIM", # flake8-simplify
|
||||||
"TCH", # flake8-type-checking
|
"TCH", # flake8-type-checking
|
||||||
"PTH", # flake8-use-pathlib
|
"PTH", # flake8-use-pathlib
|
||||||
"RUF", # Ruff-specific rules
|
"RUF", # Ruff-specific rules
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
"E501", # line too long (handled by formatter)
|
"E501", # line too long (handled by formatter)
|
||||||
"B008", # do not perform function calls in argument defaults
|
"B008", # do not perform function calls in argument defaults
|
||||||
"B904", # raise without from inside except
|
"B904", # raise without from inside except
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
@@ -102,17 +115,21 @@ known-first-party = ["fastapi_traffic"]
|
|||||||
pythonVersion = "3.10"
|
pythonVersion = "3.10"
|
||||||
typeCheckingMode = "strict"
|
typeCheckingMode = "strict"
|
||||||
reportMissingTypeStubs = false
|
reportMissingTypeStubs = false
|
||||||
reportUnknownMemberType = false
|
reportUnknownMemberType = true
|
||||||
reportUnknownArgumentType = false
|
reportUnknownArgumentType = true
|
||||||
reportUnknownVariableType = false
|
reportUnknownVariableType = true
|
||||||
reportUnknownParameterType = false
|
reportUnknownParameterType = true
|
||||||
reportMissingImports = false
|
reportMissingImports = false
|
||||||
|
reportUnusedFunction = true
|
||||||
|
reportInvalidTypeArguments = true
|
||||||
|
reportGeneralTypeIssues = true
|
||||||
|
|
||||||
|
[[tool.pyright.executionEnvironments]]
|
||||||
|
root = "tests"
|
||||||
reportUnusedFunction = false
|
reportUnusedFunction = false
|
||||||
reportInvalidTypeArguments = false
|
|
||||||
reportGeneralTypeIssues = false
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "-v --tb=short"
|
addopts = "-v --tb=short"
|
||||||
@@ -125,4 +142,6 @@ dev = [
|
|||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"uvicorn>=0.40.0",
|
"uvicorn>=0.40.0",
|
||||||
|
"ruff>=0.9.9",
|
||||||
|
"pyright>=1.1.395",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ class TestConfigLoaderEnv:
|
|||||||
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "60.0",
|
"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)
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
def test_load_rate_limit_config_from_env_with_overrides(
|
def test_load_rate_limit_config_from_env_with_overrides(
|
||||||
@@ -351,7 +353,9 @@ class TestConfigLoaderJson:
|
|||||||
config_data = {"window_size": 60.0}
|
config_data = {"window_size": 60.0}
|
||||||
json_file.write_text(json.dumps(config_data))
|
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)
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
|
|
||||||
@@ -365,7 +369,7 @@ class TestConfigLoaderValidation:
|
|||||||
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algorithm",
|
"FASTAPI_TRAFFIC_RATE_LIMIT_ALGORITHM": "invalid_algorithm",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
with pytest.raises(ConfigurationError):
|
||||||
loader.load_rate_limit_config_from_env(env_vars)
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
def test_invalid_int_value(self, loader: ConfigLoader) -> None:
|
def test_invalid_int_value(self, loader: ConfigLoader) -> None:
|
||||||
@@ -374,7 +378,7 @@ class TestConfigLoaderValidation:
|
|||||||
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number",
|
"FASTAPI_TRAFFIC_RATE_LIMIT_LIMIT": "not_a_number",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
with pytest.raises(ConfigurationError):
|
||||||
loader.load_rate_limit_config_from_env(env_vars)
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
def test_invalid_float_value(self, loader: ConfigLoader) -> None:
|
def test_invalid_float_value(self, loader: ConfigLoader) -> None:
|
||||||
@@ -384,7 +388,7 @@ class TestConfigLoaderValidation:
|
|||||||
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "not_a_float",
|
"FASTAPI_TRAFFIC_RATE_LIMIT_WINDOW_SIZE": "not_a_float",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
with pytest.raises(ConfigurationError):
|
||||||
loader.load_rate_limit_config_from_env(env_vars)
|
loader.load_rate_limit_config_from_env(env_vars)
|
||||||
|
|
||||||
def test_unknown_field(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
def test_unknown_field(self, loader: ConfigLoader, temp_dir: Path) -> None:
|
||||||
@@ -411,7 +415,7 @@ class TestConfigLoaderValidation:
|
|||||||
config_data = {"limit": "not_an_int"}
|
config_data = {"limit": "not_an_int"}
|
||||||
json_file.write_text(json.dumps(config_data))
|
json_file.write_text(json.dumps(config_data))
|
||||||
|
|
||||||
with pytest.raises(ConfigurationError, match="Cannot parse value"):
|
with pytest.raises(ConfigurationError):
|
||||||
loader.load_rate_limit_config_from_json(json_file)
|
loader.load_rate_limit_config_from_json(json_file)
|
||||||
|
|
||||||
def test_bool_parsing_variations(self, loader: ConfigLoader) -> None:
|
def test_bool_parsing_variations(self, loader: ConfigLoader) -> None:
|
||||||
|
|||||||
731
uv.lock
generated
731
uv.lock
generated
@@ -1,6 +1,32 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.10"
|
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]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "backports-asyncio-runner"
|
name = "backports-asyncio-runner"
|
||||||
version = "1.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.12.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
@@ -230,12 +367,37 @@ toml = [
|
|||||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
{ 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]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
wheels = [
|
||||||
@@ -259,9 +421,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-traffic"
|
name = "fastapi-traffic"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -281,6 +444,17 @@ dev = [
|
|||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "uvicorn" },
|
{ 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 = [
|
fastapi = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
]
|
]
|
||||||
@@ -293,8 +467,10 @@ dev = [
|
|||||||
{ name = "black" },
|
{ name = "black" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -303,7 +479,10 @@ requires-dist = [
|
|||||||
{ name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100.0" },
|
{ name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.100.0" },
|
{ name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi", marker = "extra == 'fastapi'", 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 = "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 = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.350" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||||
@@ -312,21 +491,44 @@ requires-dist = [
|
|||||||
{ name = "redis", marker = "extra == 'dev'", specifier = ">=5.0.0" },
|
{ name = "redis", marker = "extra == 'dev'", specifier = ">=5.0.0" },
|
||||||
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
|
{ name = "redis", marker = "extra == 'redis'", specifier = ">=5.0.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.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 = "starlette", specifier = ">=0.27.0" },
|
||||||
{ name = "uvicorn", marker = "extra == 'dev'", specifier = ">=0.29.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]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black", specifier = ">=25.12.0" },
|
{ name = "black", specifier = ">=25.12.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.128.0" },
|
{ name = "fastapi", specifier = ">=0.128.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
|
{ name = "pyright", specifier = ">=1.1.395" },
|
||||||
{ name = "pytest", specifier = ">=9.0.2" },
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.9.9" },
|
||||||
{ name = "uvicorn", specifier = ">=0.40.0" },
|
{ 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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -373,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -382,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mypy-extensions"
|
name = "mypy-extensions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -391,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "nodeenv"
|
name = "nodeenv"
|
||||||
version = "1.10.0"
|
version = "1.10.0"
|
||||||
@@ -646,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "7.1.0"
|
version = "7.1.0"
|
||||||
@@ -658,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.14.11"
|
version = "0.14.11"
|
||||||
@@ -684,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.50.0"
|
version = "0.50.0"
|
||||||
@@ -767,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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.40.0"
|
version = "0.40.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user