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:
5
main.py
5
main.py
@@ -9,12 +9,17 @@ from models.database import init_db
|
||||
from models.migration import migration
|
||||
from utils import JWT
|
||||
from routers import router
|
||||
from service.redis import RedisManager
|
||||
from loguru import logger as l
|
||||
|
||||
# 添加初始化数据库启动项
|
||||
lifespan.add_startup(init_db)
|
||||
lifespan.add_startup(migration)
|
||||
lifespan.add_startup(JWT.load_secret_key)
|
||||
lifespan.add_startup(RedisManager.connect)
|
||||
|
||||
# 添加关闭项
|
||||
lifespan.add_shutdown(RedisManager.disconnect)
|
||||
|
||||
# 创建应用实例并设置元数据
|
||||
app = FastAPI(
|
||||
|
||||
@@ -50,17 +50,20 @@ async def 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 令牌字符串
|
||||
:return: (file_id, owner_id) 或 None(验证失败)
|
||||
:return: (jti, file_id, owner_id) 或 None(验证失败)
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("type") != "download":
|
||||
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):
|
||||
return None
|
||||
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"aiohttp>=3.13.2",
|
||||
"aiosqlite==0.22.1",
|
||||
"argon2-cffi>=25.1.0",
|
||||
"cachetools>=6.2.4",
|
||||
"fastapi[standard]>=0.122.0",
|
||||
"httpx>=0.27.0",
|
||||
"itsdangerous>=2.2.0",
|
||||
@@ -21,6 +22,7 @@ dependencies = [
|
||||
"pytest-xdist>=3.5.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"python-multipart>=0.0.20",
|
||||
"redis[hiredis]>=7.1.0",
|
||||
"sqlalchemy>=2.0.44",
|
||||
"sqlmodel>=0.0.27",
|
||||
"uvicorn>=0.38.0",
|
||||
|
||||
@@ -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 user: 当前登录用户
|
||||
|
||||
@@ -33,6 +33,7 @@ from models import (
|
||||
User,
|
||||
)
|
||||
from service.storage import LocalStorageService
|
||||
from service.redis.token_store import TokenStore
|
||||
from utils.JWT import create_download_token, DOWNLOAD_TOKEN_TTL
|
||||
from utils import http_exceptions
|
||||
|
||||
@@ -370,7 +371,7 @@ async def create_download_token_endpoint(
|
||||
@_download_router.get(
|
||||
path='/{token}',
|
||||
summary='下载文件',
|
||||
description='使用下载令牌下载文件。',
|
||||
description='使用下载令牌下载文件(一次性令牌,仅可使用一次)。',
|
||||
)
|
||||
async def download_file(
|
||||
session: SessionDep,
|
||||
@@ -380,13 +381,19 @@ async def download_file(
|
||||
下载文件端点
|
||||
|
||||
验证 JWT 令牌后返回文件内容。
|
||||
令牌为一次性使用,下载后即失效。
|
||||
"""
|
||||
# 验证令牌
|
||||
result = verify_download_token(token)
|
||||
if not result:
|
||||
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)
|
||||
|
||||
@@ -193,7 +193,10 @@ async def router_object_delete(
|
||||
user_id = user.id
|
||||
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)
|
||||
if not obj or obj.owner_id != user_id:
|
||||
continue
|
||||
@@ -212,7 +215,7 @@ async def router_object_delete(
|
||||
return ResponseBase(
|
||||
data={
|
||||
"deleted": deleted_count,
|
||||
"total": len(request.ids),
|
||||
"total": len(ids),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -248,7 +251,10 @@ async def router_object_move(
|
||||
|
||||
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)
|
||||
if not src or src.owner_id != user_id:
|
||||
continue
|
||||
@@ -290,7 +296,7 @@ async def router_object_move(
|
||||
return ResponseBase(
|
||||
data={
|
||||
"moved": moved_count,
|
||||
"total": len(request.src_ids),
|
||||
"total": len(src_ids),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
12
service/env.md
Normal file
12
service/env.md
Normal 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
88
service/redis/__init__.py
Normal 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 连接已关闭")
|
||||
65
service/redis/token_store.py
Normal file
65
service/redis/token_store.py
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import jwt
|
||||
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:
|
||||
"""
|
||||
创建文件下载令牌。
|
||||
创建一次性文件下载令牌。
|
||||
|
||||
:param file_id: 文件 ID
|
||||
:param owner_id: 文件所有者 ID
|
||||
:return: JWT 令牌字符串
|
||||
"""
|
||||
payload = {
|
||||
"jti": str(uuid4()),
|
||||
"file_id": str(file_id),
|
||||
"owner_id": str(owner_id),
|
||||
"exp": datetime.now(timezone.utc) + DOWNLOAD_TOKEN_TTL,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .password.pwd import Password, PasswordStatus
|
||||
from .http import http_exceptions
|
||||
from .conf import appmeta
|
||||
@@ -11,14 +11,36 @@ description = 'DiskNext Server 是一款基于 FastAPI 的网盘系统,支持
|
||||
license_info = {"name": "GPLv3", "url": "https://opensource.org/license/gpl-3.0"}
|
||||
|
||||
BackendVersion = "0.0.1"
|
||||
"""后端版本"""
|
||||
|
||||
IsPro = False
|
||||
|
||||
mode: str = os.getenv('MODE', 'master')
|
||||
"""运行模式"""
|
||||
|
||||
debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") or False
|
||||
"""是否启用调试模式"""
|
||||
|
||||
if debug:
|
||||
log.warning("Debug mode is enabled. This is not recommended for production use.")
|
||||
|
||||
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
74
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cbor2"
|
||||
version = "5.7.1"
|
||||
@@ -430,6 +439,7 @@ dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "itsdangerous" },
|
||||
@@ -442,6 +452,7 @@ dependencies = [
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "uvicorn" },
|
||||
@@ -454,6 +465,7 @@ requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||
{ name = "aiosqlite", specifier = "==0.22.1" },
|
||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||
{ name = "cachetools", specifier = ">=6.2.4" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.122.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "itsdangerous", specifier = ">=2.2.0" },
|
||||
@@ -466,6 +478,7 @@ requires-dist = [
|
||||
{ name = "pytest-xdist", specifier = ">=3.5.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=7.1.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.27" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "httpcore"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
|
||||
Reference in New Issue
Block a user