commit ee1a370ee55194fe888d4f71a2fdb92a2c523a11 Author: chakcy <947105045@qq.com> Date: Mon Aug 17 15:07:29 2026 +0800 add: chongming-cache 统一缓存工具(diskcache / redis 双后端,dict-like 接口) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8f0442 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d995e80 --- /dev/null +++ b/README.md @@ -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 打包产物不引入) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a54c895 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/chongming_cache/__init__.py b/src/chongming_cache/__init__.py new file mode 100644 index 0000000..f28b554 --- /dev/null +++ b/src/chongming_cache/__init__.py @@ -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__"] diff --git a/src/chongming_cache/backends/__init__.py b/src/chongming_cache/backends/__init__.py new file mode 100644 index 0000000..ad07a2a --- /dev/null +++ b/src/chongming_cache/backends/__init__.py @@ -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"] diff --git a/src/chongming_cache/backends/base.py b/src/chongming_cache/backends/base.py new file mode 100644 index 0000000..bfc2dd1 --- /dev/null +++ b/src/chongming_cache/backends/base.py @@ -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: + """释放后端连接/句柄。""" diff --git a/src/chongming_cache/backends/disk.py b/src/chongming_cache/backends/disk.py new file mode 100644 index 0000000..d50092c --- /dev/null +++ b/src/chongming_cache/backends/disk.py @@ -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() diff --git a/src/chongming_cache/backends/redis_.py b/src/chongming_cache/backends/redis_.py new file mode 100644 index 0000000..dfde58a --- /dev/null +++ b/src/chongming_cache/backends/redis_.py @@ -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() diff --git a/src/chongming_cache/cache.py b/src/chongming_cache/cache.py new file mode 100644 index 0000000..729a24c --- /dev/null +++ b/src/chongming_cache/cache.py @@ -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() diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..90ff3a8 --- /dev/null +++ b/tests/test_cache.py @@ -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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bd155ab --- /dev/null +++ b/uv.lock @@ -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" }, +]