add: chongming-cache 统一缓存工具(diskcache / redis 双后端,dict-like 接口)
This commit is contained in:
+72
@@ -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
|
||||||
@@ -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 打包产物不引入)
|
||||||
@@ -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"]
|
||||||
@@ -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__"]
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
"""释放后端连接/句柄。"""
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user