add: chongming-cache 统一缓存工具(diskcache / redis 双后端,dict-like 接口)

This commit is contained in:
2026-08-17 15:07:29 +08:00
commit ee1a370ee5
11 changed files with 905 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
/target
# Byte-compiled / optimized / DLL files
__pycache__/
.pytest_cache/
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
.venv/
env/
bin/
build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
var/
include/
man/
venv/
*.egg-info/
.installed.cfg
*.egg
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
pip-selfcheck.json
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Rope
.ropeproject
# Django stuff:
*.log
*.pot
.DS_Store
# Sphinx documentation
docs/_build/
# PyCharm
.idea/
# VSCode
.vscode/
# Pyenv
.python-version
+92
View File
@@ -0,0 +1,92 @@
# chongming-cache
统一缓存封装,dict-like 接口,后端可切换 **diskcache** / **redis**,通过配置一行切换,业务代码零改动。
---
## 安装
```bash
uv pip install -e utils/cache
# 或作为依赖接入(与 chongming-database 等同约定):
# [tool.uv.sources]
# chongming-cache = { git = "http://124.71.68.6:3000/chongming/chongming-utils-cache.git" }
```
---
## 快速开始
```python
from chongming_cache import Cache
# 方式一:从配置创建(主项目 config.toml 的 [cache] 段)
cache = Cache.from_config(config["cache"])
# 方式二:显式参数
cache = Cache("diskcache", cache_store_path="./cache_data")
# cache = Cache("redis", redis_host="127.0.0.1", redis_port=6379, redis_prefix="chongming:")
```
### 映射协议(与 diskcache 用法一致)
```python
cache["key"] = {"a": 1} # 写入(无 TTL)
if "key" in cache: # 存在性检查(含 TTL 判断)
value = cache["key"] # 读取,未命中抛 KeyError
del cache["key"] # 删除,未命中抛 KeyError
len(cache) # 条目数
for k in cache: ... # 迭代 key
```
### 扩展接口
```python
cache.get("key", default=None) # 未命中返回 default
cache.set("key", value, expire=60) # 写入,TTL 秒(None = 永久)
cache.touch("key", expire=120) # 刷新 TTL,返回是否命中(未命中 False)
cache.delete("key") # 删除,返回是否命中
cache.clear() # 清空(redis 仅清本前缀命名空间)
cache.close() # 释放连接
with Cache.from_config(cfg) as cache: ... # 上下文管理器,退出自动 close
cache.client # 底层客户端逃生口(diskcache.Cache / redis.Redis)
```
---
## 后端配置(config.toml `[cache]` 段)
```toml
[cache]
backend = "diskcache" # "diskcache"(默认) | "redis"
cache_store_path = "./cache_data"
# redis 后端专用(backend = "redis" 时生效,均有默认值)
# redis_host = "127.0.0.1"
# redis_port = 6379
# redis_db = 0
# redis_password = ""
# redis_prefix = "chongming:"
```
所有键均可缺省:旧配置(只有 `cache_store_path`)自动按 diskcache 后端工作。
---
## 双后端语义对齐
| 行为 | diskcache | redis |
| ---- | --------- | ----- |
| 值序列化 | pickle | pickle(保持任意 Python 对象语义一致) |
| 未命中 `cache[key]` | `KeyError` | `KeyError` |
| `set(..., expire=N)` | 秒级 TTL,读时惰性淘汰 | `SET EX N`,到期即消失 |
| `touch(未命中)` | 返回 False | 返回 False |
| `clear()` | 清空本地缓存 | 仅清 `redis_prefix` 命名空间(不 flushdb) |
| 多进程安全 | 是(SQLite + 锁) | 是(服务端原子性) |
## 依赖
- `diskcache` — 本地持久化后端
- `redis` — 远程后端(懒加载,diskcache-only 打包产物不引入)
+23
View File
@@ -0,0 +1,23 @@
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]
[project]
name = "chongming-cache"
version = "0.1.0"
description = "统一缓存封装:兼容 diskcache / redis 后端"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"diskcache>=5.6.3",
"redis>=5.0.0",
]
[dependency-groups]
dev = [
"pytest>=8.0.0",
"fakeredis>=2.28.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/chongming_cache"]
+6
View File
@@ -0,0 +1,6 @@
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
from chongming_cache.cache import Cache
__version__ = "0.1.0"
__all__ = ["Cache", "CacheBackend", "DiskBackend", "RedisBackend", "__version__"]
+5
View File
@@ -0,0 +1,5 @@
from chongming_cache.backends.base import CacheBackend
from chongming_cache.backends.disk import DiskBackend
from chongming_cache.backends.redis_ import RedisBackend
__all__ = ["CacheBackend", "DiskBackend", "RedisBackend"]
+66
View File
@@ -0,0 +1,66 @@
"""缓存后端抽象接口:dict-like 映射 + TTL。"""
from abc import ABC, abstractmethod
from typing import Any, Iterator
class CacheBackend(ABC):
"""缓存后端统一接口。
语义约定(与 diskcache 对齐):
- ``__getitem__`` 未命中抛 ``KeyError``;``get(key, default)`` 未命中返回 default
- ``set(key, value, expire=None)`` 中 expire 为秒(int/float),None 表示永久
- ``touch`` 返回是否命中(未命中返回 False);``delete`` 未命中返回 False
"""
# ── 映射协议 ──────────────────────────────────────────────
@abstractmethod
def __contains__(self, key) -> bool:
"""key 是否存在且未过期。"""
@abstractmethod
def __getitem__(self, key) -> Any:
"""取值,未命中抛 KeyError。"""
@abstractmethod
def __setitem__(self, key, value) -> None:
"""写入(不设 TTL)。"""
@abstractmethod
def __delitem__(self, key) -> None:
"""删除,未命中抛 KeyError。"""
@abstractmethod
def __len__(self) -> int:
"""有效条目数。"""
@abstractmethod
def __iter__(self) -> Iterator:
"""迭代所有 key。"""
# ── 扩展接口 ──────────────────────────────────────────────
@abstractmethod
def get(self, key, default=None) -> Any:
"""取值,未命中返回 default。"""
@abstractmethod
def set(self, key, value, expire: float | None = None) -> None:
"""写入,可带 TTL(秒)。"""
@abstractmethod
def touch(self, key, expire: float | None = None) -> bool:
"""刷新 key 的 TTL,返回是否命中(未命中返回 False)。"""
@abstractmethod
def delete(self, key) -> bool:
"""删除,返回是否命中。"""
@abstractmethod
def clear(self) -> None:
"""清空本缓存实例可见的全部条目。"""
@abstractmethod
def close(self) -> None:
"""释放后端连接/句柄。"""
+60
View File
@@ -0,0 +1,60 @@
"""diskcache 后端:基于 SQLite 的本地持久化缓存,多进程安全。"""
from pathlib import Path
from typing import Any, Iterator
from diskcache import Cache as DiskCache
from chongming_cache.backends.base import CacheBackend
class DiskBackend(CacheBackend):
"""包装 diskcache.Cache,提供统一接口。
Args:
path: 缓存目录(SQLite 数据文件所在目录),父目录不存在时自动创建。
"""
def __init__(self, path: str | Path):
self._cache = DiskCache(path)
@property
def client(self) -> DiskCache:
"""底层 diskcache.Cache 实例(逃生口)。"""
return self._cache
def __contains__(self, key) -> bool:
return key in self._cache
def __getitem__(self, key) -> Any:
return self._cache[key]
def __setitem__(self, key, value) -> None:
self._cache[key] = value
def __delitem__(self, key) -> None:
del self._cache[key]
def __len__(self) -> int:
return len(self._cache)
def __iter__(self) -> Iterator:
return iter(self._cache)
def get(self, key, default=None) -> Any:
return self._cache.get(key, default)
def set(self, key, value, expire: float | None = None) -> None:
self._cache.set(key, value, expire=expire)
def touch(self, key, expire: float | None = None) -> bool:
return self._cache.touch(key, expire=expire)
def delete(self, key) -> bool:
return self._cache.delete(key)
def clear(self) -> None:
self._cache.clear()
def close(self) -> None:
self._cache.close()
+124
View File
@@ -0,0 +1,124 @@
"""redis 后端:跨进程/跨主机共享缓存。
redis-py 采用懒加载:仅在选择 redis 后端时才 import,
使用 diskcache 后端的打包产物不会引入 redis 依赖。
"""
import pickle
from typing import Any, Iterator
from chongming_cache.backends.base import CacheBackend
# SCAN 批大小与 clear() 管道批大小
_SCAN_COUNT = 1000
class RedisBackend(CacheBackend):
"""包装 redis-py 客户端,提供统一接口。
值使用 pickle 序列化(与 diskcache 默认序列化语义对齐,支持任意 Python 对象);
``prefix`` 作为 key 命名空间前缀,便于多实例共享同一 Redis 时互不干扰。
Args:
host: Redis 地址
port: Redis 端口
db: 数据库编号
password: 认证密码(无密码为 None)
prefix: key 前缀(命名空间),如 ``"chongming:"``
**kwargs: 其余参数透传 redis.Redis(如 socket_timeout)
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 6379,
db: int = 0,
password: str | None = None,
prefix: str = "",
**kwargs,
):
try:
import redis
except ImportError as e: # pragma: no cover
raise ImportError("使用 redis 后端需要安装 redis 包") from e
self._redis = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=False,
**kwargs,
)
self._prefix = prefix
@property
def client(self):
"""底层 redis.Redis 客户端(逃生口)。"""
return self._redis
def _key(self, key) -> bytes:
if isinstance(key, bytes):
return self._prefix.encode() + key
return self._prefix.encode() + str(key).encode()
def _unkey(self, raw: bytes) -> str:
"""去掉前缀并解码为 str。"""
return raw[len(self._prefix) :].decode()
def __contains__(self, key) -> bool:
return bool(self._redis.exists(self._key(key)))
def __getitem__(self, key) -> Any:
raw = self._redis.get(self._key(key))
if raw is None:
raise KeyError(key)
return pickle.loads(raw)
def __setitem__(self, key, value) -> None:
self._redis.set(self._key(key), pickle.dumps(value))
def __delitem__(self, key) -> None:
if not self._redis.delete(self._key(key)):
raise KeyError(key)
def __len__(self) -> int:
return sum(
1 for _ in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT)
)
def __iter__(self) -> Iterator:
for raw in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT):
yield self._unkey(raw)
def get(self, key, default=None) -> Any:
raw = self._redis.get(self._key(key))
if raw is None:
return default
return pickle.loads(raw)
def set(self, key, value, expire: float | None = None) -> None:
self._redis.set(self._key(key), pickle.dumps(value), ex=expire)
def touch(self, key, expire: float | None = None) -> bool:
# expire=None 时 redis-py 发 PERSIST(永久),与 diskcache.touch 语义一致
return bool(self._redis.expire(self._key(key), expire))
def delete(self, key) -> bool:
return bool(self._redis.delete(self._key(key)))
def clear(self) -> None:
"""仅清除本前缀下的 key(SCAN+管道批量 DEL,不 flushdb)。"""
pipe = self._redis.pipeline()
count = 0
for raw in self._redis.scan_iter(match=self._prefix + "*", count=_SCAN_COUNT):
pipe.delete(raw)
count += 1
if count >= _SCAN_COUNT:
pipe.execute()
count = 0
if count:
pipe.execute()
def close(self) -> None:
self._redis.close()
+121
View File
@@ -0,0 +1,121 @@
"""统一缓存门面:dict-like 接口,后端可切换 diskcache / redis。"""
from typing import Any, Iterator, Mapping
from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend
_KNOWN_BACKENDS = {"diskcache", "redis"}
class Cache:
"""统一缓存封装。
- 映射协议:``in`` / ``cache[key]`` / ``cache[key] = value`` / ``del`` / ``len`` / ``iter``
- 扩展接口:``get`` / ``set(key, value, expire=秒)`` / ``touch`` / ``delete`` / ``clear`` / ``close``
- 支持上下文管理器(退出时自动 close)
通过 ``backend`` 参数或 ``from_config`` 切换后端:
from chongming_cache import Cache
cache = Cache.from_config(config["cache"]) # 读取 [cache] 段
cache.set("k", {"a": 1}, expire=60)
if "k" in cache:
value = cache["k"]
"""
def __init__(self, backend: str = "diskcache", **params):
"""创建缓存实例。
Args:
backend: "diskcache" 或 "redis"
**params: 后端参数,见 ``from_config`` 说明
"""
self._backend = self._create_backend(backend, params)
@classmethod
def from_config(cls, config: Mapping) -> "Cache":
"""从配置字典创建缓存实例,读取形如主项目 config.toml 的 ``[cache]`` 段:
- ``backend``: "diskcache"(默认)或 "redis"
- ``cache_store_path``: diskcache 缓存目录(默认 "./cache_data")
- ``redis_host`` / ``redis_port`` / ``redis_db`` / ``redis_password`` / ``redis_prefix``:
redis 后端连接参数(仅 backend = "redis" 时生效)
所有键均可缺省,旧配置(只有 cache_store_path)直接兼容。
"""
cfg = dict(config)
return cls(cfg.pop("backend", "diskcache"), **cfg)
@staticmethod
def _create_backend(backend: str, params: Mapping) -> CacheBackend:
name = (backend or "diskcache").strip().lower()
if name not in _KNOWN_BACKENDS:
raise ValueError(f"不支持的缓存后端: {backend!r}(可选 diskcache / redis)")
if name == "diskcache":
return DiskBackend(params.get("cache_store_path", "./cache_data"))
return RedisBackend(
host=params.get("redis_host", "127.0.0.1"),
port=int(params.get("redis_port", 6379)),
db=int(params.get("redis_db", 0)),
password=params.get("redis_password") or None,
prefix=params.get("redis_prefix", "") or "",
)
@property
def backend(self) -> CacheBackend:
"""后端封装实例(DiskBackend / RedisBackend)。"""
return self._backend
@property
def client(self):
"""底层客户端(diskcache.Cache / redis.Redis),逃生口。"""
return self._backend.client
# ── 映射协议 ──────────────────────────────────────────────
def __contains__(self, key) -> bool:
return key in self._backend
def __getitem__(self, key) -> Any:
return self._backend[key]
def __setitem__(self, key, value) -> None:
self._backend[key] = value
def __delitem__(self, key) -> None:
del self._backend[key]
def __len__(self) -> int:
return len(self._backend)
def __iter__(self) -> Iterator:
return iter(self._backend)
# ── 扩展接口 ──────────────────────────────────────────────
def get(self, key, default=None) -> Any:
return self._backend.get(key, default)
def set(self, key, value, expire: float | None = None) -> None:
self._backend.set(key, value, expire)
def touch(self, key, expire: float | None = None) -> bool:
return self._backend.touch(key, expire)
def delete(self, key) -> bool:
return self._backend.delete(key)
def clear(self) -> None:
self._backend.clear()
def close(self) -> None:
self._backend.close()
def __enter__(self) -> "Cache":
return self
def __exit__(self, *exc) -> None:
self.close()
+205
View File
@@ -0,0 +1,205 @@
"""chongming-cache 双后端测试。
redis 后端优先连接真实服务(REDIS_HOST/REDIS_PORT),不可用时
在进程内启动 fakeredis TCP 服务兜底,保证 redis 后端逻辑始终被验证。
"""
import os
import threading
import time
import pytest
from chongming_cache import Cache
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 15)) # 测试专用库号,避免污染默认库
_server = None # fakeredis 兜底服务(进程内)
def _get_redis_endpoint() -> tuple[str, int]:
"""真实 redis 可用则用之;否则启动 fakeredis TCP 服务兜底。"""
global _server
import redis
try:
client = redis.Redis(
host=REDIS_HOST, port=REDIS_PORT, socket_connect_timeout=1
)
if client.ping():
client.close()
return REDIS_HOST, REDIS_PORT
except Exception:
pass
import fakeredis
_server = fakeredis.TcpFakeServer(("127.0.0.1", 0), server_type="redis")
port = _server.server_address[1]
threading.Thread(target=_server.serve_forever, daemon=True).start()
return "127.0.0.1", port
_redis_host, _redis_port = _get_redis_endpoint()
@pytest.fixture(scope="session", autouse=True)
def _shutdown_fake_redis():
"""会话结束时显式关闭 fakeredis 服务,避免其非 daemon 线程卡住解释器退出。"""
yield
if _server is not None:
_server.shutdown()
_server.server_close()
@pytest.fixture
def disk_cache(tmp_path):
cache = Cache.from_config(
{"backend": "diskcache", "cache_store_path": str(tmp_path / "cache")}
)
yield cache
cache.close()
@pytest.fixture
def redis_cache():
"""redis 后端实例(唯一前缀命名空间,测试结束只清自己的 key)。"""
cache = Cache.from_config(
{
"backend": "redis",
"redis_host": _redis_host,
"redis_port": _redis_port,
"redis_db": REDIS_DB,
"redis_prefix": f"test:cache:{os.getpid()}:",
}
)
yield cache
cache.clear()
cache.close()
def _mapping_checks(cache: Cache) -> None:
cache["k1"] = {"a": 1}
cache.set("k2", [1, 2, 3])
cache.set("k3", "bytes-able", expire=60)
assert "k1" in cache
assert cache["k1"] == {"a": 1}
assert cache["k2"] == [1, 2, 3]
assert cache.get("k3") == "bytes-able"
assert cache.get("missing") is None
assert cache.get("missing", "d") == "d"
with pytest.raises(KeyError):
cache["missing"]
assert len(cache) == 3
assert set(cache) == {"k1", "k2", "k3"}
del cache["k1"]
assert "k1" not in cache
with pytest.raises(KeyError):
del cache["k1"]
assert cache.delete("k2") is True
assert cache.delete("k2") is False
assert len(cache) == 1
cache.clear()
assert len(cache) == 0
def test_mapping_disk(disk_cache):
_mapping_checks(disk_cache)
def test_expire_disk(disk_cache):
disk_cache.set("e", 1, expire=1)
assert "e" in disk_cache
time.sleep(1.2)
assert "e" not in disk_cache # diskcache 读时惰性淘汰
assert disk_cache.get("e", "miss") == "miss"
def test_touch_disk(disk_cache):
disk_cache.set("e", 1, expire=1)
assert disk_cache.touch("e", expire=10) is True
time.sleep(1.2)
assert "e" in disk_cache
assert disk_cache.touch("missing", expire=10) is False
def test_context_manager(tmp_path):
with Cache.from_config(
{"backend": "diskcache", "cache_store_path": str(tmp_path / "cm")}
) as cache:
cache["x"] = 1
assert cache["x"] == 1
def test_from_config_defaults(tmp_path, monkeypatch):
# 缺省 backend 时默认 diskcache;缺省 cache_store_path 时用 ./cache_data
monkeypatch.chdir(tmp_path)
cache = Cache.from_config({})
try:
cache.set("k", 1)
assert cache["k"] == 1
finally:
cache.close()
def test_invalid_backend(tmp_path):
with pytest.raises(ValueError):
Cache.from_config({"backend": "memcached", "cache_store_path": str(tmp_path)})
# ── redis 后端 ──────────────────────────────────────────────────
def test_mapping_redis(redis_cache):
_mapping_checks(redis_cache)
def test_expire_redis(redis_cache):
redis_cache.set("e", 1, expire=1)
assert "e" in redis_cache
time.sleep(1.2)
assert "e" not in redis_cache # TTL 到期后 EXISTS 为假
def test_touch_redis(redis_cache):
redis_cache.set("e", 1, expire=1)
assert redis_cache.touch("e", expire=10) is True
time.sleep(1.2)
assert "e" in redis_cache
assert redis_cache.touch("missing", expire=10) is False
def test_prefix_isolation():
"""不同前缀实例共享同一 Redis,clear 只清自己的 key。"""
base = {
"backend": "redis",
"redis_host": _redis_host,
"redis_port": _redis_port,
"redis_db": REDIS_DB,
}
a = Cache.from_config({**base, "redis_prefix": f"test:iso:a:{os.getpid()}:"})
b = Cache.from_config({**base, "redis_prefix": f"test:iso:b:{os.getpid()}:"})
try:
a["x"] = 1
b["y"] = 2
a.clear()
assert "x" not in a and "y" in b
assert len(a) == 0 and len(b) == 1
assert set(b) == {"y"}
finally:
a.clear()
b.clear()
a.close()
b.close()
def test_redis_binary_value_roundtrip(redis_cache):
"""非 pickle 可序列化值应正常往返(dict/bytes 等)。"""
value = {"nested": {"list": [1, 2, 3]}, "bytes": b"\x00\x01"}
redis_cache["k"] = value
assert redis_cache["k"] == value
Generated
+131
View File
@@ -0,0 +1,131 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "chongming-cache"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "diskcache" },
{ name = "redis" },
]
[package.dev-dependencies]
dev = [
{ name = "fakeredis" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "diskcache", specifier = ">=5.6.3" },
{ name = "redis", specifier = ">=5.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "fakeredis", specifier = ">=2.28.0" },
{ name = "pytest", specifier = ">=8.0.0" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "diskcache"
version = "5.6.3"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19" },
]
[[package]]
name = "fakeredis"
version = "2.37.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cb/34/73256495885f68b125b6a95f6a8448e103adf34825746ab8905db52183a0/fakeredis-2.37.0.tar.gz", hash = "sha256:7461f124dcba04a80691d72270b3d1d5cd100ef14dc068c76db825940f3ed799", size = 236339, upload-time = "2026-07-22T20:04:53.753Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/76/e2/964e6ef372770dd7c32f9738b50ff4924f1d3cccd665b568680e4bcb0167/fakeredis-2.37.0-py3-none-any.whl", hash = "sha256:657a2a695a1123be0c13f98db409371497bd94c29d260dd76a9fc7ce1a633745", size = 151526, upload-time = "2026-07-22T20:04:51.979Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/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 = "packaging"
version = "26.3"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "redis"
version = "8.1.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" }
sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]