Add Redis-based one-time download token support

Integrate Redis as a backend for one-time download token validation, with in-memory fallback. Added RedisManager for connection lifecycle, TokenStore for atomic token usage checks, and related configuration via environment variables. Updated download flow to ensure tokens are single-use, and improved API robustness for batch operations. Updated dependencies to include redis and cachetools.
This commit is contained in:
2025-12-26 18:47:46 +08:00
parent 3088a9d548
commit d8a229fccd
13 changed files with 302 additions and 15 deletions

View File

@@ -9,12 +9,17 @@ from models.database import init_db
from models.migration import migration from models.migration import migration
from utils import JWT from utils import JWT
from routers import router from routers import router
from service.redis import RedisManager
from loguru import logger as l from loguru import logger as l
# 添加初始化数据库启动项 # 添加初始化数据库启动项
lifespan.add_startup(init_db) lifespan.add_startup(init_db)
lifespan.add_startup(migration) lifespan.add_startup(migration)
lifespan.add_startup(JWT.load_secret_key) lifespan.add_startup(JWT.load_secret_key)
lifespan.add_startup(RedisManager.connect)
# 添加关闭项
lifespan.add_shutdown(RedisManager.disconnect)
# 创建应用实例并设置元数据 # 创建应用实例并设置元数据
app = FastAPI( app = FastAPI(

View File

@@ -50,17 +50,20 @@ async def admin_required(
raise http_exceptions.raise_forbidden("Admin Required") raise http_exceptions.raise_forbidden("Admin Required")
def verify_download_token(token: str) -> tuple[UUID, UUID] | None: def verify_download_token(token: str) -> tuple[str, UUID, UUID] | None:
""" """
验证下载令牌并返回 (file_id, owner_id)。 验证下载令牌并返回 (jti, file_id, owner_id)。
:param token: JWT 令牌字符串 :param token: JWT 令牌字符串
:return: (file_id, owner_id) 或 None验证失败 :return: (jti, file_id, owner_id) 或 None验证失败
""" """
try: try:
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"]) payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
if payload.get("type") != "download": if payload.get("type") != "download":
return None return None
return UUID(payload["file_id"]), UUID(payload["owner_id"]) jti = payload.get("jti")
if not jti:
return None
return jti, UUID(payload["file_id"]), UUID(payload["owner_id"])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None return None

View File

@@ -9,6 +9,7 @@ dependencies = [
"aiohttp>=3.13.2", "aiohttp>=3.13.2",
"aiosqlite==0.22.1", "aiosqlite==0.22.1",
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"cachetools>=6.2.4",
"fastapi[standard]>=0.122.0", "fastapi[standard]>=0.122.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
@@ -21,6 +22,7 @@ dependencies = [
"pytest-xdist>=3.5.0", "pytest-xdist>=3.5.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"python-multipart>=0.0.20", "python-multipart>=0.0.20",
"redis[hiredis]>=7.1.0",
"sqlalchemy>=2.0.44", "sqlalchemy>=2.0.44",
"sqlmodel>=0.0.27", "sqlmodel>=0.0.27",
"uvicorn>=0.38.0", "uvicorn>=0.38.0",

View File

@@ -32,7 +32,8 @@ async def router_directory_get(
""" """
获取目录内容 获取目录内容
路径必须以用户名开头,如 /api/directory/admin 或 /api/directory/admin/docs 路径必须以用户名或 `.crash` 开头,如 /api/directory/admin 或 /api/directory/admin/docs
`.crash` 代表回收站,也就意味着用户名禁止为 `.crash`
:param session: 数据库会话 :param session: 数据库会话
:param user: 当前登录用户 :param user: 当前登录用户

View File

@@ -33,6 +33,7 @@ from models import (
User, User,
) )
from service.storage import LocalStorageService from service.storage import LocalStorageService
from service.redis.token_store import TokenStore
from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL
from utils import http_exceptions from utils import http_exceptions
@@ -370,7 +371,7 @@ async def create_download_token_endpoint(
@_download_router.get( @_download_router.get(
path='/{token}', path='/{token}',
summary='下载文件', summary='下载文件',
description='使用下载令牌下载文件。', description='使用下载令牌下载文件(一次性令牌,仅可使用一次)',
) )
async def download_file( async def download_file(
session: SessionDep, session: SessionDep,
@@ -380,13 +381,19 @@ async def download_file(
下载文件端点 下载文件端点
验证 JWT 令牌后返回文件内容。 验证 JWT 令牌后返回文件内容。
令牌为一次性使用,下载后即失效。
""" """
# 验证令牌 # 验证令牌
result = verify_download_token(token) result = verify_download_token(token)
if not result: if not result:
raise HTTPException(status_code=401, detail="下载令牌无效或已过期") raise HTTPException(status_code=401, detail="下载令牌无效或已过期")
file_id, owner_id = result jti, file_id, owner_id = result
# 检查并标记令牌已使用(原子操作)
is_first_use = await TokenStore.mark_used(jti, DOWNLOAD_TOKEN_TTL)
if not is_first_use:
raise HTTPException(status_code=404)
# 获取文件对象 # 获取文件对象
file_obj = await Object.get(session, Object.id == file_id) file_obj = await Object.get(session, Object.id == file_id)

View File

@@ -193,7 +193,10 @@ async def router_object_delete(
user_id = user.id user_id = user.id
deleted_count = 0 deleted_count = 0
for obj_id in request.ids: # 处理单个 UUID 或 UUID 列表
ids = request.ids if isinstance(request.ids, list) else [request.ids]
for obj_id in ids:
obj = await Object.get(session, Object.id == obj_id) obj = await Object.get(session, Object.id == obj_id)
if not obj or obj.owner_id != user_id: if not obj or obj.owner_id != user_id:
continue continue
@@ -212,7 +215,7 @@ async def router_object_delete(
return ResponseBase( return ResponseBase(
data={ data={
"deleted": deleted_count, "deleted": deleted_count,
"total": len(request.ids), "total": len(ids),
} }
) )
@@ -248,7 +251,10 @@ async def router_object_move(
moved_count = 0 moved_count = 0
for src_id in request.src_ids: # 处理单个 UUID 或 UUID 列表
src_ids = request.src_ids if isinstance(request.src_ids, list) else [request.src_ids]
for src_id in src_ids:
src = await Object.get(session, Object.id == src_id) src = await Object.get(session, Object.id == src_id)
if not src or src.owner_id != user_id: if not src or src.owner_id != user_id:
continue continue
@@ -290,7 +296,7 @@ async def router_object_move(
return ResponseBase( return ResponseBase(
data={ data={
"moved": moved_count, "moved": moved_count,
"total": len(request.src_ids), "total": len(src_ids),
} }
) )

12
service/env.md Normal file
View File

@@ -0,0 +1,12 @@
# 环境变量字段
- `MODE` str 运行模式,默认 `master`
- `master` 主机模式
- `slave` 从机模式
- `DEBUG` bool 是否开启调试模式,默认 `false`
- `DATABASE_URL`: 数据库连接信息,默认 `sqlite+aiosqlite:///disknext.db`
- `REDIS_HOST`: Redis 主机地址
- `REDIS_PORT`: Redis 端口
- `REDIS_PASSWORD`: Redis 密码
- `REDIS_DB`: Redis 数据库
- `REDIS_PROTOCOL`

88
service/redis/__init__.py Normal file
View File

@@ -0,0 +1,88 @@
"""
Redis 服务模块
提供 Redis 异步客户端的生命周期管理。
通过环境变量配置连接参数,未配置时不初始化客户端。
"""
from typing import ClassVar
import redis.asyncio as aioredis
from loguru import logger as l
from utils.conf import appmeta
class RedisManager:
"""
Redis 连接管理器
使用 ClassVar 管理全局单例客户端。
"""
_client: ClassVar[aioredis.Redis | None] = None
"""Redis 客户端实例"""
_is_available: ClassVar[bool] = False
"""Redis 是否可用"""
@classmethod
def is_configured(cls) -> bool:
"""检查是否配置了 Redis"""
return appmeta.redis_url is not None
@classmethod
def is_available(cls) -> bool:
"""检查 Redis 是否可用"""
return cls._is_available
@classmethod
def get_client(cls) -> aioredis.Redis | None:
"""
获取 Redis 客户端实例。
:return: Redis 客户端,未配置或连接失败时返回 None
"""
return cls._client if cls._is_available else None
@classmethod
async def connect(cls) -> None:
"""
连接 Redis 服务器。
在应用启动时调用,如果未配置 Redis 则跳过。
"""
if not cls.is_configured():
l.info("未配置 REDIS_URL跳过 Redis 连接")
return
try:
cls._client = aioredis.Redis(
host=appmeta.redis_url,
port=appmeta.redis_port,
password=appmeta.redis_password,
db=appmeta.redis_db,
protocol=appmeta.redis_protocol,
)
# 测试连接
await cls._client.ping()
cls._is_available = True
l.info(f"Redis 连接成功: {appmeta.redis_url}:{appmeta.redis_port}")
except Exception as e:
l.warning(f"Redis 连接失败,将使用内存缓存作为降级方案: {e}")
cls._client = None
cls._is_available = False
@classmethod
async def disconnect(cls) -> None:
"""
断开 Redis 连接。
在应用关闭时调用。
"""
if cls._client is not None:
await cls._client.close()
cls._client = None
cls._is_available = False
l.info("Redis 连接已关闭")

View File

@@ -0,0 +1,65 @@
"""
一次性令牌状态存储
支持 Redis首选和内存缓存降级两种存储后端。
"""
from datetime import timedelta
from typing import ClassVar
from cachetools import TTLCache
from loguru import logger as l
from . import RedisManager
class TokenStore:
"""
一次性令牌存储管理器
根据 Redis 可用性自动选择存储后端:
- Redis 可用:使用 Redis支持分布式部署
- Redis 不可用:使用内存缓存(仅单实例)
"""
_memory_cache: ClassVar[TTLCache[str, bool]] = TTLCache(maxsize=10000, ttl=3600)
"""内存缓存降级方案"""
@classmethod
async def mark_used(cls, jti: str, ttl: timedelta | int) -> bool:
"""
标记令牌为已使用(原子操作)。
:param jti: 令牌唯一标识符JWT ID
:param ttl: 过期时间
:return: True 表示首次标记成功可以使用False 表示已被使用
"""
ttl_seconds = int(ttl.total_seconds()) if isinstance(ttl, timedelta) else ttl
client = RedisManager.get_client()
if client is not None:
# 使用 Redis SETNX 原子操作
key = f"download_token:{jti}"
result = await client.set(key, "1", nx=True, ex=ttl_seconds)
return result is not None
else:
# 降级使用内存缓存
if jti in cls._memory_cache:
return False
cls._memory_cache[jti] = True
return True
@classmethod
async def is_used(cls, jti: str) -> bool:
"""
检查令牌是否已被使用。
:param jti: 令牌唯一标识符
:return: True 表示已被使用
"""
client = RedisManager.get_client()
if client is not None:
key = f"download_token:{jti}"
return await client.exists(key) > 0
else:
return jti in cls._memory_cache

View File

@@ -1,5 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID, uuid4
import jwt import jwt
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
@@ -108,13 +108,14 @@ DOWNLOAD_TOKEN_TTL = timedelta(hours=1)
def create_download_token(file_id: UUID, owner_id: UUID) -> str: def create_download_token(file_id: UUID, owner_id: UUID) -> str:
""" """
创建文件下载令牌。 创建一次性文件下载令牌。
:param file_id: 文件 ID :param file_id: 文件 ID
:param owner_id: 文件所有者 ID :param owner_id: 文件所有者 ID
:return: JWT 令牌字符串 :return: JWT 令牌字符串
""" """
payload = { payload = {
"jti": str(uuid4()),
"file_id": str(file_id), "file_id": str(file_id),
"owner_id": str(owner_id), "owner_id": str(owner_id),
"exp": datetime.now(timezone.utc) + DOWNLOAD_TOKEN_TTL, "exp": datetime.now(timezone.utc) + DOWNLOAD_TOKEN_TTL,

View File

@@ -1,2 +1,3 @@
from .password.pwd import Password, PasswordStatus from .password.pwd import Password, PasswordStatus
from .http import http_exceptions from .http import http_exceptions
from .conf import appmeta

View File

@@ -11,14 +11,36 @@ description = 'DiskNext Server 是一款基于 FastAPI 的网盘系统,支持
license_info = {"name": "GPLv3", "url": "https://opensource.org/license/gpl-3.0"} license_info = {"name": "GPLv3", "url": "https://opensource.org/license/gpl-3.0"}
BackendVersion = "0.0.1" BackendVersion = "0.0.1"
"""后端版本"""
IsPro = False IsPro = False
mode: str = os.getenv('MODE', 'master') mode: str = os.getenv('MODE', 'master')
"""运行模式"""
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") or False debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") or False
"""是否启用调试模式"""
if debug: if debug:
log.warning("Debug mode is enabled. This is not recommended for production use.") log.warning("Debug mode is enabled. This is not recommended for production use.")
database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///disknext.db") database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///disknext.db")
"""数据库地址"""
redis_url: str | None = os.getenv("REDIS_URL")
"""Redis 主机地址"""
_redis_port = os.getenv("REDIS_PORT")
redis_port: int = int(_redis_port) if _redis_port else 6379
"""Redis 端口,默认 6379"""
redis_password: str | None = os.getenv("REDIS_PASSWORD")
"""Redis 密码"""
_redis_db = os.getenv("REDIS_DB")
redis_db: int = int(_redis_db) if _redis_db else 0
"""Redis 数据库索引,默认 0"""
_redis_protocol = os.getenv("REDIS_PROTOCOL")
redis_protocol: int = int(_redis_protocol) if _redis_protocol else 3
"""Redis 协议版本,默认 3"""

74
uv.lock generated
View File

@@ -204,6 +204,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
] ]
[[package]]
name = "cachetools"
version = "6.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" },
]
[[package]] [[package]]
name = "cbor2" name = "cbor2"
version = "5.7.1" version = "5.7.1"
@@ -430,6 +439,7 @@ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "cachetools" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "httpx" }, { name = "httpx" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
@@ -442,6 +452,7 @@ dependencies = [
{ name = "pytest-xdist" }, { name = "pytest-xdist" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "redis", extra = ["hiredis"] },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn" }, { name = "uvicorn" },
@@ -454,6 +465,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiohttp", specifier = ">=3.13.2" },
{ name = "aiosqlite", specifier = "==0.22.1" }, { name = "aiosqlite", specifier = "==0.22.1" },
{ name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "cachetools", specifier = ">=6.2.4" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.122.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.122.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "itsdangerous", specifier = ">=2.2.0" }, { name = "itsdangerous", specifier = ">=2.2.0" },
@@ -466,6 +478,7 @@ requires-dist = [
{ name = "pytest-xdist", specifier = ">=3.5.0" }, { name = "pytest-xdist", specifier = ">=3.5.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-multipart", specifier = ">=0.0.20" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.1.0" },
{ name = "sqlalchemy", specifier = ">=2.0.44" }, { name = "sqlalchemy", specifier = ">=2.0.44" },
{ name = "sqlmodel", specifier = ">=0.0.27" }, { name = "sqlmodel", specifier = ">=0.0.27" },
{ name = "uvicorn", specifier = ">=0.38.0" }, { name = "uvicorn", specifier = ">=0.38.0" },
@@ -735,6 +748,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "hiredis"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048, upload-time = "2025-10-14T16:33:34.263Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035, upload-time = "2025-10-14T16:32:23.715Z" },
{ url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219, upload-time = "2025-10-14T16:32:24.554Z" },
{ url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860, upload-time = "2025-10-14T16:32:25.356Z" },
{ url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094, upload-time = "2025-10-14T16:32:26.148Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746, upload-time = "2025-10-14T16:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465, upload-time = "2025-10-14T16:32:29.185Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419, upload-time = "2025-10-14T16:32:30.059Z" },
{ url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400, upload-time = "2025-10-14T16:32:31.173Z" },
{ url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845, upload-time = "2025-10-14T16:32:32.12Z" },
{ url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365, upload-time = "2025-10-14T16:32:33.035Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022, upload-time = "2025-10-14T16:32:34.81Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533, upload-time = "2025-10-14T16:32:35.7Z" },
{ url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387, upload-time = "2025-10-14T16:32:36.441Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b3/b948ee76a6b2bc7e45249861646f91f29704f743b52565cf64cee9c4658b/hiredis-3.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c135bda87211f7af9e2fd4e046ab433c576cd17b69e639a0f5bb2eed5e0e71a9", size = 82105, upload-time = "2025-10-14T16:32:37.204Z" },
{ url = "https://files.pythonhosted.org/packages/a2/9b/4210f4ebfb3ab4ada964b8de08190f54cbac147198fb463cd3c111cc13e0/hiredis-3.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f855c678230aed6fc29b962ce1cc67e5858a785ef3a3fd6b15dece0487a2e60", size = 46237, upload-time = "2025-10-14T16:32:38.07Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7a/e38bfd7d04c05036b4ccc6f42b86b1032185cf6ae426e112a97551fece14/hiredis-3.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4059c78a930cbb33c391452ccce75b137d6f89e2eebf6273d75dafc5c2143c03", size = 41894, upload-time = "2025-10-14T16:32:38.929Z" },
{ url = "https://files.pythonhosted.org/packages/28/d3/eae43d9609c5d9a6effef0586ee47e13a0d84b44264b688d97a75cd17ee5/hiredis-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:334a3f1d14c253bb092e187736c3384203bd486b244e726319bbb3f7dffa4a20", size = 170486, upload-time = "2025-10-14T16:32:40.147Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fd/34d664554880b27741ab2916d66207357563b1639e2648685f4c84cfb755/hiredis-3.3.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd137b147235447b3d067ec952c5b9b95ca54b71837e1b38dbb2ec03b89f24fc", size = 182031, upload-time = "2025-10-14T16:32:41.06Z" },
{ url = "https://files.pythonhosted.org/packages/08/a3/0c69fdde3f4155b9f7acc64ccffde46f312781469260061b3bbaa487fd34/hiredis-3.3.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f88f4f2aceb73329ece86a1cb0794fdbc8e6d614cb5ca2d1023c9b7eb432db8", size = 180542, upload-time = "2025-10-14T16:32:42.993Z" },
{ url = "https://files.pythonhosted.org/packages/68/7a/ad5da4d7bc241e57c5b0c4fe95aa75d1f2116e6e6c51577394d773216e01/hiredis-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:550f4d1538822fc75ebf8cf63adc396b23d4958bdbbad424521f2c0e3dfcb169", size = 172353, upload-time = "2025-10-14T16:32:43.965Z" },
{ url = "https://files.pythonhosted.org/packages/4b/dc/c46eace64eb047a5b31acd5e4b0dc6d2f0390a4a3f6d507442d9efa570ad/hiredis-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54b14211fbd5930fc696f6fcd1f1f364c660970d61af065a80e48a1fa5464dd6", size = 166435, upload-time = "2025-10-14T16:32:44.97Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ac/ad13a714e27883a2e4113c980c94caf46b801b810de5622c40f8d3e8335f/hiredis-3.3.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9e96f63dbc489fc86f69951e9f83dadb9582271f64f6822c47dcffa6fac7e4a", size = 177218, upload-time = "2025-10-14T16:32:45.936Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/268fabd85b225271fe1ba82cb4a484fcc1bf922493ff2c74b400f1a6f339/hiredis-3.3.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:106e99885d46684d62ab3ec1d6b01573cc0e0083ac295b11aaa56870b536c7ec", size = 170477, upload-time = "2025-10-14T16:32:46.898Z" },
{ url = "https://files.pythonhosted.org/packages/20/6b/02bb8af810ea04247334ab7148acff7a61c08a8832830c6703f464be83a9/hiredis-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:087e2ef3206361281b1a658b5b4263572b6ba99465253e827796964208680459", size = 167915, upload-time = "2025-10-14T16:32:47.847Z" },
{ url = "https://files.pythonhosted.org/packages/83/94/901fa817e667b2e69957626395e6dee416e31609dca738f28e6b545ca6c2/hiredis-3.3.0-cp314-cp314-win32.whl", hash = "sha256:80638ebeab1cefda9420e9fedc7920e1ec7b4f0513a6b23d58c9d13c882f8065", size = 21165, upload-time = "2025-10-14T16:32:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/b1/7e/4881b9c1d0b4cdaba11bd10e600e97863f977ea9d67c5988f7ec8cd363e5/hiredis-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a68aaf9ba024f4e28cf23df9196ff4e897bd7085872f3a30644dca07fa787816", size = 22996, upload-time = "2025-10-14T16:32:51.543Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b6/d7e6c17da032665a954a89c1e6ee3bd12cb51cd78c37527842b03519981d/hiredis-3.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f7f80442a32ce51ee5d89aeb5a84ee56189a0e0e875f1a57bbf8d462555ae48f", size = 83034, upload-time = "2025-10-14T16:32:52.395Z" },
{ url = "https://files.pythonhosted.org/packages/27/6c/6751b698060cdd1b2d8427702cff367c9ed7a1705bcf3792eb5b896f149b/hiredis-3.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1a67530da714954ed50579f4fe1ab0ddbac9c43643b1721c2cb226a50dde263", size = 46701, upload-time = "2025-10-14T16:32:53.572Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8e/20a5cf2c83c7a7e08c76b9abab113f99f71cd57468a9c7909737ce6e9bf8/hiredis-3.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:616868352e47ab355559adca30f4f3859f9db895b4e7bc71e2323409a2add751", size = 42381, upload-time = "2025-10-14T16:32:54.762Z" },
{ url = "https://files.pythonhosted.org/packages/be/0a/547c29c06e8c9c337d0df3eec39da0cf1aad701daf8a9658dd37f25aca66/hiredis-3.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e799b79f3150083e9702fc37e6243c0bd47a443d6eae3f3077b0b3f510d6a145", size = 180313, upload-time = "2025-10-14T16:32:55.644Z" },
{ url = "https://files.pythonhosted.org/packages/89/8a/488de5469e3d0921a1c425045bf00e983d48b2111a90e47cf5769eaa536c/hiredis-3.3.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ef1dfb0d2c92c3701655e2927e6bbe10c499aba632c7ea57b6392516df3864b", size = 190488, upload-time = "2025-10-14T16:32:56.649Z" },
{ url = "https://files.pythonhosted.org/packages/b5/59/8493edc3eb9ae0dbea2b2230c2041a52bc03e390b02ffa3ac0bca2af9aea/hiredis-3.3.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c290da6bc2a57e854c7da9956cd65013483ede935677e84560da3b848f253596", size = 189210, upload-time = "2025-10-14T16:32:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/f0/de/8c9a653922057b32fb1e2546ecd43ef44c9aa1a7cf460c87cae507eb2bc7/hiredis-3.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd8c438d9e1728f0085bf9b3c9484d19ec31f41002311464e75b69550c32ffa8", size = 180972, upload-time = "2025-10-14T16:32:58.737Z" },
{ url = "https://files.pythonhosted.org/packages/e4/a3/51e6e6afaef2990986d685ca6e254ffbd191f1635a59b2d06c9e5d10c8a2/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bbc6b8a88bbe331e3ebf6685452cebca6dfe6d38a6d4efc5651d7e363ba28bd", size = 175315, upload-time = "2025-10-14T16:32:59.774Z" },
{ url = "https://files.pythonhosted.org/packages/96/54/e436312feb97601f70f8b39263b8da5ac4a5d18305ebdfb08ad7621f6119/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:55d8c18fe9a05496c5c04e6eccc695169d89bf358dff964bcad95696958ec05f", size = 185653, upload-time = "2025-10-14T16:33:00.749Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a3/88e66030d066337c6c0f883a912c6d4b2d6d7173490fbbc113a6cbe414ff/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4ddc79afa76b805d364e202a754666cb3c4d9c85153cbfed522871ff55827838", size = 179032, upload-time = "2025-10-14T16:33:01.711Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" },
{ url = "https://files.pythonhosted.org/packages/66/14/0dc2b99209c400f3b8f24067273e9c3cb383d894e155830879108fb19e98/hiredis-3.3.0-cp314-cp314t-win32.whl", hash = "sha256:298593bb08487753b3afe6dc38bac2532e9bac8dcee8d992ef9977d539cc6776", size = 22024, upload-time = "2025-10-14T16:33:03.812Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/8a0befeed8bbe142d5a6cf3b51e8cbe019c32a64a596b0ebcbc007a8f8f1/hiredis-3.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", size = 23808, upload-time = "2025-10-14T16:33:04.965Z" },
]
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "1.0.9" version = "1.0.9"
@@ -1335,6 +1395,20 @@ wheels = [
{ 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" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "redis"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
]
[package.optional-dependencies]
hiredis = [
{ name = "hiredis" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.2.0"