diff --git a/main.py b/main.py index 61c05a9..f1a3335 100644 --- a/main.py +++ b/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( diff --git a/middleware/auth.py b/middleware/auth.py index 5ce7f42..b57a664 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ffa6a17..3bdb291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/routers/api/v1/directory/__init__.py b/routers/api/v1/directory/__init__.py index d7b3e5c..40f93cd 100644 --- a/routers/api/v1/directory/__init__.py +++ b/routers/api/v1/directory/__init__.py @@ -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: 当前登录用户 diff --git a/routers/api/v1/file/__init__.py b/routers/api/v1/file/__init__.py index 380a90a..9eabfb0 100644 --- a/routers/api/v1/file/__init__.py +++ b/routers/api/v1/file/__init__.py @@ -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) diff --git a/routers/api/v1/object/__init__.py b/routers/api/v1/object/__init__.py index e1114af..a0243a4 100644 --- a/routers/api/v1/object/__init__.py +++ b/routers/api/v1/object/__init__.py @@ -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), } ) diff --git a/service/env.md b/service/env.md new file mode 100644 index 0000000..d5e9bad --- /dev/null +++ b/service/env.md @@ -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` \ No newline at end of file diff --git a/service/redis/__init__.py b/service/redis/__init__.py new file mode 100644 index 0000000..63b8009 --- /dev/null +++ b/service/redis/__init__.py @@ -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 连接已关闭") diff --git a/service/redis/token_store.py b/service/redis/token_store.py new file mode 100644 index 0000000..6acb97b --- /dev/null +++ b/service/redis/token_store.py @@ -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 diff --git a/utils/JWT/__init__.py b/utils/JWT/__init__.py index 41c2d6b..6e50030 100644 --- a/utils/JWT/__init__.py +++ b/utils/JWT/__init__.py @@ -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, diff --git a/utils/__init__.py b/utils/__init__.py index c07d153..974e394 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,2 +1,3 @@ from .password.pwd import Password, PasswordStatus -from .http import http_exceptions \ No newline at end of file +from .http import http_exceptions +from .conf import appmeta \ No newline at end of file diff --git a/utils/conf/appmeta.py b/utils/conf/appmeta.py index 39de330..7c92fbe 100644 --- a/utils/conf/appmeta.py +++ b/utils/conf/appmeta.py @@ -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") \ No newline at end of file +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""" \ No newline at end of file diff --git a/uv.lock b/uv.lock index e89ec1e..7b075bc 100644 --- a/uv.lock +++ b/uv.lock @@ -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"