From 08657fa166f57b8fde02f7c7932e5051bf5af641 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Fri, 4 Sep 2026 14:44:06 +0800 Subject: [PATCH] =?UTF-8?q?modify:=20=E6=B8=85=E9=99=A4diskcache=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 +++-- pyproject.toml | 4 +- src/chongming_cache/__init__.py | 8 +- src/chongming_cache/backends/__init__.py | 4 +- src/chongming_cache/backends/base.py | 75 ++++++++++ src/chongming_cache/backends/disk.py | 64 -------- src/chongming_cache/backends/redis_.py | 41 ++++-- src/chongming_cache/backends/sqlite_.py | 178 +++++++++++++++++++++++ src/chongming_cache/cache.py | 37 +++-- tests/test_cache.py | 36 ++++- uv.lock | 56 ++++++- 11 files changed, 424 insertions(+), 115 deletions(-) delete mode 100644 src/chongming_cache/backends/disk.py create mode 100644 src/chongming_cache/backends/sqlite_.py diff --git a/README.md b/README.md index fdc9b81..b87ca9b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # chongming-cache -统一缓存封装,dict-like 接口,后端可切换 **diskcache** / **redis**,通过配置一行切换,业务代码零改动。 +统一缓存封装,dict-like 接口,后端可切换 **sqlite**(本机)/ **redis**(跨主机),通过配置一行切换,业务代码零改动。 --- @@ -24,11 +24,12 @@ from chongming_cache import Cache cache = Cache.from_config(config["cache"]) # 方式二:显式参数 -cache = Cache("diskcache", cache_store_path="./cache_data") +cache = Cache("sqlite", path="./cache_data/cache.db") # cache = Cache("redis", redis_host="127.0.0.1", redis_port=6379, redis_prefix="chongming:") +# 历史后端名 "diskcache" 兼容映射为 sqlite ``` -### 映射协议(与 diskcache 用法一致) +### 映射协议(与 dict / diskcache 用法一致) ```python cache["key"] = {"a": 1} # 写入(无 TTL) @@ -51,7 +52,7 @@ cache.close() # 释放连接 with Cache.from_config(cfg) as cache: ... # 上下文管理器,退出自动 close -cache.client # 底层客户端逃生口(diskcache.Cache / redis.Redis) +cache.client # 底层客户端逃生口(sqlite3.Connection / redis.Redis) # 原子写(分布式锁原语):仅当 key 不存在时写入,成功返回 True,已存在返回 False,不覆盖 ok = cache.backend.set_nx("leader", {"node": "gw-1"}, expire=30) @@ -85,7 +86,7 @@ asyncio.run(main()) - `acquire()`:SET NX,原子地仅在不存在时写入;key 带 TTL 租约,持有方宕机后到期自动让出,不会死锁。 - `start()` / `stop()`:后台续约任务;续约前校验 token,仅在自己仍持有时 `touch`,锁丢失即停止续约。 - `release()`:受保护释放——仅当 token 仍匹配(仍是自己持有)才删除,绝不误删其他进程/主机抢到的锁。 -- **后端适用性**:`redis` 真正跨主机共享;`diskcache` 进程安全(SQLite),可用于单机多进程或共享卷场景,不可跨无共享文件系统的主机。 +- **后端适用性**:`redis` 真正跨主机共享;`sqlite` 基于共享文件(SQLite 多进程安全),可用于单机多进程或共享卷场景,不可跨无共享文件系统的主机。 --- @@ -93,8 +94,8 @@ asyncio.run(main()) ```toml [cache] -backend = "diskcache" # "diskcache"(默认) | "redis" -cache_store_path = "./cache_data" +backend = "sqlite" # "sqlite"(默认,本机共享) | "redis"(跨主机) +path = "./cache_data/cache.db" # 兼容旧键 cache_store_path(指向目录时自动追加 cache.db) # redis 后端专用(backend = "redis" 时生效,均有默认值) # redis_host = "127.0.0.1" @@ -104,22 +105,29 @@ cache_store_path = "./cache_data" # redis_prefix = "chongming:" ``` -所有键均可缺省:旧配置(只有 `cache_store_path`)自动按 diskcache 后端工作。 +所有键均可缺省:旧配置(只有 `cache_store_path`)自动按 sqlite 后端工作。 --- ## 双后端语义对齐 -| 行为 | diskcache | redis | -| ---- | --------- | ----- | -| 值序列化 | pickle | pickle(保持任意 Python 对象语义一致) | +| 行为 | sqlite(本机) | redis(跨主机) | +| ---- | ----------- | ----------- | +| 值序列化 | JSON(orjson) | JSON(orjson)(与 Rust 进程插件 serde_json 互通) | | 未命中 `cache[key]` | `KeyError` | `KeyError` | | `set(..., expire=N)` | 秒级 TTL,读时惰性淘汰 | `SET EX N`,到期即消失 | | `touch(未命中)` | 返回 False | 返回 False | | `clear()` | 清空本地缓存 | 仅清 `redis_prefix` 命名空间(不 flushdb) | -| 多进程安全 | 是(SQLite + 锁) | 是(服务端原子性) | +| 多进程安全 | 是(SQLite 文件 + WAL) | 是(服务端原子性) | + +## 跨语言共享(Rust 插件 / sqlite) + +sqlite 后端把值以 JSON 文本存进表 `cache(k TEXT PRIMARY KEY, v TEXT NOT NULL, expire REAL)`, +schema 与 Rust 进程插件(`chongming-worker-rs`,feature `local-cache`)完全一致——Python 与 +Rust 读写**同一份 `.db` 文件**即可互通,无需 Redis。Rust 侧读侧不改变该 schema。 ## 依赖 -- `diskcache` — 本地持久化后端 -- `redis` — 远程后端(懒加载,diskcache-only 打包产物不引入) +- `sqlite3` — 本地共享后端(Python 标准库,零第三方依赖) +- `redis` — 远程后端(懒加载,仅选择 redis 后端时才 import) +- `orjson` — 全后端统一 JSON 序列化(缺失时回退标准库 `json`) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b1f10f9..5910aee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ requires = ["hatchling"] [project] name = "chongming-cache" version = "0.2.0" -description = "统一缓存封装:兼容 diskcache / redis 后端" +description = "统一缓存封装:兼容 sqlite(本地) / redis(共享) 后端" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "diskcache>=5.6.3", "redis>=5.0.0", + "orjson>=3.9.0", ] [dependency-groups] diff --git a/src/chongming_cache/__init__.py b/src/chongming_cache/__init__.py index d6ae466..fc2dee6 100644 --- a/src/chongming_cache/__init__.py +++ b/src/chongming_cache/__init__.py @@ -1,14 +1,18 @@ -from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend +from chongming_cache.backends import CacheBackend, RedisBackend, SqliteBackend from chongming_cache.cache import Cache from chongming_cache.lock import DistributedLock +# 兼容旧名:原 diskcache 后端已由 SQLite 后端 (SqliteBackend) 直接替换 +DiskBackend = SqliteBackend + __version__ = "0.2.0" __all__ = [ "Cache", "CacheBackend", "DiskBackend", + "SqliteBackend", "RedisBackend", "DistributedLock", "__version__", -] +] \ No newline at end of file diff --git a/src/chongming_cache/backends/__init__.py b/src/chongming_cache/backends/__init__.py index ad07a2a..7060086 100644 --- a/src/chongming_cache/backends/__init__.py +++ b/src/chongming_cache/backends/__init__.py @@ -1,5 +1,5 @@ from chongming_cache.backends.base import CacheBackend -from chongming_cache.backends.disk import DiskBackend from chongming_cache.backends.redis_ import RedisBackend +from chongming_cache.backends.sqlite_ import SqliteBackend -__all__ = ["CacheBackend", "DiskBackend", "RedisBackend"] +__all__ = ["CacheBackend", "SqliteBackend", "RedisBackend"] \ No newline at end of file diff --git a/src/chongming_cache/backends/base.py b/src/chongming_cache/backends/base.py index ca467a5..eec6cea 100644 --- a/src/chongming_cache/backends/base.py +++ b/src/chongming_cache/backends/base.py @@ -1,8 +1,83 @@ """缓存后端抽象接口:dict-like 映射 + TTL。""" +import base64 +import pickle from abc import ABC, abstractmethod from typing import Any, Iterator +try: + import orjson + + def _dumps(value: Any) -> bytes: + # default:bytes 无法原生 JSON 化,包成 {__b64__: base64},读取时还原为 bytes + def _default(obj: Any) -> Any: + if isinstance(obj, bytes): + return {_B64_MARKER: base64.b64encode(obj).decode("ascii")} + raise TypeError(f"无法序列化为 JSON: {type(obj)!r}") + + return orjson.dumps(value, default=_default) + + def _loads(raw) -> Any: + return orjson.loads(raw) + +except ImportError: # pragma: no cover + import json + + def _dumps(value: Any) -> bytes: + def _default(obj: Any) -> Any: + if isinstance(obj, bytes): + return {_B64_MARKER: base64.b64encode(obj).decode("ascii")} + raise TypeError(f"无法序列化为 JSON: {type(obj)!r}") + + return json.dumps(value, ensure_ascii=False, default=_default).encode("utf-8") + + def _loads(raw) -> Any: + return json.loads(raw.decode("utf-8")) + +# bytes 的 base64 包裹标记:让 bytes(如 RPC 缓存回复)在 JSON 下也能无损往返。 +_B64_MARKER = "__b64__" + +# 所有后端统一以 JSON 持久化:跨后端(redis/本地 diskcache)语义一致, +# 且与 Rust 进程插件(serde_json / json)的共享协议互通。 + + +def _restore_bytes(obj: Any) -> Any: + """递归还原被包裹的 bytes。""" + if isinstance(obj, dict): + if len(obj) == 1 and _B64_MARKER in obj: + try: + return base64.b64decode(obj[_B64_MARKER]) + except Exception: + pass + return {k: _restore_bytes(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_restore_bytes(v) for v in obj] + return obj + + +def encode_value(value: Any) -> bytes: + """统一序列化:JSON(orjson)。bytes 以 base64 包裹;datetime/set/自定义对象不支持。""" + return _dumps(value) + + +def decode_value(raw: Any) -> Any: + """统一反序列化。 + + - 字节值优先按 JSON 解析(orjson);bytes 从 base64 包裹还原; + - 解析失败回退 pickle(兼容历史 pickled 数据,跨后端切换窗口); + - 若后端已把对象透明还原(旧磁盘直存路径),非字节则原样返回。 + """ + if not isinstance(raw, (bytes, bytearray)): + return raw + try: + return _restore_bytes(_loads(raw)) + except Exception: + pass + try: + return pickle.loads(raw) + except Exception: + return raw + class CacheBackend(ABC): """缓存后端统一接口。 diff --git a/src/chongming_cache/backends/disk.py b/src/chongming_cache/backends/disk.py deleted file mode 100644 index ec43fd1..0000000 --- a/src/chongming_cache/backends/disk.py +++ /dev/null @@ -1,64 +0,0 @@ -"""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 set_nx(self, key, value, expire: float | None = None) -> bool: - # diskcache.add 仅在 key 不存在时写入,返回是否成功(SET NX 语义)且进程安全 - return bool(self._cache.add(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 index 0167b2e..7e5a58f 100644 --- a/src/chongming_cache/backends/redis_.py +++ b/src/chongming_cache/backends/redis_.py @@ -2,12 +2,16 @@ redis-py 采用懒加载:仅在选择 redis 后端时才 import, 使用 diskcache 后端的打包产物不会引入 redis 依赖。 + +值统一使用 JSON(orjson)序列化,与本地 diskcache 后端及 Rust 进程插件 +(serde_json)共享同一套跨语言协议;``prefix`` 作为 key 命名空间前缀。 """ -import pickle +import datetime from typing import Any, Iterator -from chongming_cache.backends.base import CacheBackend +from chongming_cache.backends.base import CacheBackend, decode_value, encode_value + # SCAN 批大小与 clear() 管道批大小 _SCAN_COUNT = 1000 @@ -16,8 +20,8 @@ _SCAN_COUNT = 1000 class RedisBackend(CacheBackend): """包装 redis-py 客户端,提供统一接口。 - 值使用 pickle 序列化(与 diskcache 默认序列化语义对齐,支持任意 Python 对象); - ``prefix`` 作为 key 命名空间前缀,便于多实例共享同一 Redis 时互不干扰。 + 值统一使用 JSON(orjson)序列化;``prefix`` 作为 key 命名空间前缀, + 便于多实例共享同一 Redis 时互不干扰。 Args: host: Redis 地址 @@ -66,6 +70,19 @@ class RedisBackend(CacheBackend): """去掉前缀并解码为 str。""" return raw[len(self._prefix) :].decode() + @staticmethod + def _expire_secs(expire): + """把秒(可能是 float)规范为 redis-py 可接受的取值。 + + redis-py 的 ``SET EX`` / ``EXPIRE`` 只接受 ``int`` 或 ``datetime.timedelta``, + 传入 float(如 DistributedLock 默认 ttl=30.0)会抛 ``DataError``。 + """ + if isinstance(expire, datetime.timedelta): + return expire + if expire is not None: + return int(expire) + return None + def __contains__(self, key) -> bool: return bool(self._redis.exists(self._key(key))) @@ -73,10 +90,10 @@ class RedisBackend(CacheBackend): raw = self._redis.get(self._key(key)) if raw is None: raise KeyError(key) - return pickle.loads(raw) + return decode_value(raw) def __setitem__(self, key, value) -> None: - self._redis.set(self._key(key), pickle.dumps(value)) + self._redis.set(self._key(key), encode_value(value)) def __delitem__(self, key) -> None: if not self._redis.delete(self._key(key)): @@ -95,25 +112,27 @@ class RedisBackend(CacheBackend): raw = self._redis.get(self._key(key)) if raw is None: return default - return pickle.loads(raw) + return decode_value(raw) def set(self, key, value, expire: float | None = None) -> None: - self._redis.set(self._key(key), pickle.dumps(value), ex=expire) + self._redis.set( + self._key(key), encode_value(value), ex=self._expire_secs(expire) + ) def set_nx(self, key, value, expire: float | None = None) -> bool: # redis SET key val NX EX ttl:原子地仅在不存在时写入,用于分布式锁 return bool( self._redis.set( self._key(key), - pickle.dumps(value), - ex=expire, + encode_value(value), + ex=self._expire_secs(expire), nx=True, ) ) 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)) + return bool(self._redis.expire(self._key(key), self._expire_secs(expire))) def delete(self, key) -> bool: return bool(self._redis.delete(self._key(key))) diff --git a/src/chongming_cache/backends/sqlite_.py b/src/chongming_cache/backends/sqlite_.py new file mode 100644 index 0000000..ce9cab4 --- /dev/null +++ b/src/chongming_cache/backends/sqlite_.py @@ -0,0 +1,178 @@ +"""sqlite 后端:基于标准库 SQLite 的本地共享键值缓存(替代原 diskcache 后端)。 + +值以 JSON(orjson) 存入 TEXT 列,序列化协议与 redis 后端 / Rust 进程插件(serde_json) +共享同一套 schema,因此 Rust 侧(sqlx)可直接读写同一份 .db 文件,实现真正的跨语言 +本地共享(无需 Redis)。 + +- 本地、多进程安全:WAL + busy_timeout + SQLite 行锁(等价于原 diskcache 的多进程语义); +- TTL:``expire`` 列存到期 epoch 秒,读时惰性淘汰(对齐 diskcache/redis 的 get 语义)。 + +共享 schema(Ruby/Python 与 Rust 必须一致): + + CREATE TABLE IF NOT EXISTS cache ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL, -- JSON 文本 + expire REAL -- 到期 epoch 秒;NULL 表示永不过期 + ); +""" + +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Iterator + +from chongming_cache.backends.base import CacheBackend, decode_value, encode_value + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS cache ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL, + expire REAL +); +""" + + +class SqliteBackend(CacheBackend): + """包装 sqlite3,提供统一接口(与 Rust 侧共享同一份 schema)。 + + Args: + path: SQLite 数据库文件路径(父目录不存在时自动创建)。 + """ + + def __init__(self, path: str | Path): + p = Path(path) + if p.parent and str(p.parent) != ".": + p.parent.mkdir(parents=True, exist_ok=True) + self._path = str(p) + self._lock = threading.RLock() + self._conn = sqlite3.connect(self._path, check_same_thread=False) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA busy_timeout=5000") + self._conn.execute(_SCHEMA) + self._conn.commit() + + @property + def client(self) -> sqlite3.Connection: + """底层 sqlite3.Connection 实例(逃生口)。""" + return self._conn + + @staticmethod + def _expire_secs(expire) -> float | None: + """秒级 TTL -> 到期 epoch 时间戳;None 表示永不过期。""" + if expire is None: + return None + return time.time() + float(expire) + + def _live_value(self, k: str) -> str | None: + """取未过期的原始 JSON 文本;过期或缺失返回 None(过期时顺手删除)。""" + row = self._conn.execute( + "SELECT v, expire FROM cache WHERE k = ?", (k,) + ).fetchone() + if row is None: + return None + v, expire = row + if expire is not None and expire <= time.time(): + self._conn.execute("DELETE FROM cache WHERE k = ?", (k,)) + self._conn.commit() + return None + return v + + def __contains__(self, key) -> bool: + with self._lock: + return self._live_value(str(key)) is not None + + def __getitem__(self, key) -> Any: + with self._lock: + raw = self._live_value(str(key)) + if raw is None: + raise KeyError(key) + return decode_value(raw.encode("utf-8")) + + def __setitem__(self, key, value) -> None: + with self._lock: + self._set(str(key), value, None) + + def __delitem__(self, key) -> None: + with self._lock: + if not self.delete(key): + raise KeyError(key) + + def __len__(self) -> int: + with self._lock: + now = time.time() + self._conn.execute( + "DELETE FROM cache WHERE expire IS NOT NULL AND expire <= ?", (now,) + ) + self._conn.commit() + return self._conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0] + + def __iter__(self) -> Iterator: + with self._lock: + now = time.time() + self._conn.execute( + "DELETE FROM cache WHERE expire IS NOT NULL AND expire <= ?", (now,) + ) + self._conn.commit() + rows = self._conn.execute("SELECT k FROM cache").fetchall() + return iter(row[0] for row in rows) + + def get(self, key, default=None) -> Any: + with self._lock: + raw = self._live_value(str(key)) + if raw is None: + return default + return decode_value(raw.encode("utf-8")) + + def _set(self, k: str, value: Any, expire) -> None: + v = encode_value(value).decode("utf-8") + self._conn.execute( + "INSERT INTO cache(k, v, expire) VALUES(?, ?, ?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v, expire=excluded.expire", + (k, v, expire), + ) + self._conn.commit() + + def set(self, key, value, expire: float | None = None) -> None: + with self._lock: + self._set(str(key), value, self._expire_secs(expire)) + + def set_nx(self, key, value, expire: float | None = None) -> bool: + with self._lock: + k = str(key) + v = encode_value(value).decode("utf-8") + cur = self._conn.execute( + "INSERT OR IGNORE INTO cache(k, v, expire) VALUES(?, ?, ?)", + (k, v, self._expire_secs(expire)), + ) + self._conn.commit() + return cur.rowcount == 1 + + def touch(self, key, expire: float | None = None) -> bool: + with self._lock: + k = str(key) + if self._live_value(k) is None: + return False + self._conn.execute( + "UPDATE cache SET expire=? WHERE k=?", (self._expire_secs(expire), k) + ) + self._conn.commit() + return True + + def delete(self, key) -> bool: + with self._lock: + cur = self._conn.execute("DELETE FROM cache WHERE k = ?", (str(key),)) + self._conn.commit() + return cur.rowcount > 0 + + def clear(self) -> None: + with self._lock: + self._conn.execute("DELETE FROM cache") + self._conn.commit() + + def close(self) -> None: + with self._lock: + try: + self._conn.close() + except Exception: + pass \ No newline at end of file diff --git a/src/chongming_cache/cache.py b/src/chongming_cache/cache.py index 729a24c..08dac51 100644 --- a/src/chongming_cache/cache.py +++ b/src/chongming_cache/cache.py @@ -1,10 +1,14 @@ -"""统一缓存门面:dict-like 接口,后端可切换 diskcache / redis。""" +"""统一缓存门面:dict-like 接口,后端可切换 sqlite(本地) / redis(共享)。""" +from pathlib import Path from typing import Any, Iterator, Mapping -from chongming_cache.backends import CacheBackend, DiskBackend, RedisBackend +from chongming_cache.backends import CacheBackend, RedisBackend, SqliteBackend -_KNOWN_BACKENDS = {"diskcache", "redis"} +_KNOWN_BACKENDS = {"sqlite", "redis"} +# 旧后端名 diskcache → sqlite(新本地后端直接替换了原 diskcache 实现) +_BACKEND_ALIASES = {"diskcache": "sqlite"} +_DEFAULT_PATH = "./cache_data/cache.db" class Cache: @@ -24,11 +28,11 @@ class Cache: value = cache["k"] """ - def __init__(self, backend: str = "diskcache", **params): + def __init__(self, backend: str = "sqlite", **params): """创建缓存实例。 Args: - backend: "diskcache" 或 "redis" + backend: "sqlite" 或 "redis"(旧名 "diskcache" 兼容映射到 sqlite) **params: 后端参数,见 ``from_config`` 说明 """ self._backend = self._create_backend(backend, params) @@ -37,24 +41,29 @@ class Cache: def from_config(cls, config: Mapping) -> "Cache": """从配置字典创建缓存实例,读取形如主项目 config.toml 的 ``[cache]`` 段: - - ``backend``: "diskcache"(默认)或 "redis" - - ``cache_store_path``: diskcache 缓存目录(默认 "./cache_data") + - ``backend``: "sqlite"(默认,本地共享)或 "redis"(跨主机) + - ``path`` / ``cache_store_path``: sqlite 数据库文件路径(默认 "./cache_data/cache.db") - ``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) + return cls(cfg.pop("backend", "sqlite"), **cfg) @staticmethod def _create_backend(backend: str, params: Mapping) -> CacheBackend: - name = (backend or "diskcache").strip().lower() + name = _BACKEND_ALIASES.get((backend or "sqlite").strip().lower(), (backend or "sqlite").strip().lower()) if name not in _KNOWN_BACKENDS: - raise ValueError(f"不支持的缓存后端: {backend!r}(可选 diskcache / redis)") + raise ValueError(f"不支持的缓存后端: {backend!r}(可选 sqlite / redis)") - if name == "diskcache": - return DiskBackend(params.get("cache_store_path", "./cache_data")) + if name == "sqlite": + path = params.get("path") or params.get("cache_store_path") or _DEFAULT_PATH + p = Path(path) + # 兼容旧 diskcache 配置:路径若指向目录则追加默认 db 文件名 + if path.endswith(("/", "\\")) or p.is_dir(): + p = p / "cache.db" + return SqliteBackend(p) return RedisBackend( host=params.get("redis_host", "127.0.0.1"), @@ -66,12 +75,12 @@ class Cache: @property def backend(self) -> CacheBackend: - """后端封装实例(DiskBackend / RedisBackend)。""" + """后端封装实例(SqliteBackend / RedisBackend)。""" return self._backend @property def client(self): - """底层客户端(diskcache.Cache / redis.Redis),逃生口。""" + """底层客户端(sqlite3.Connection / redis.Redis),逃生口。""" return self._backend.client # ── 映射协议 ────────────────────────────────────────────── diff --git a/tests/test_cache.py b/tests/test_cache.py index 90ff3a8..425e0af 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -5,6 +5,7 @@ redis 后端优先连接真实服务(REDIS_HOST/REDIS_PORT),不可用时 """ import os +import sqlite3 import threading import time @@ -57,7 +58,7 @@ def _shutdown_fake_redis(): @pytest.fixture def disk_cache(tmp_path): cache = Cache.from_config( - {"backend": "diskcache", "cache_store_path": str(tmp_path / "cache")} + {"backend": "sqlite", "path": str(tmp_path / "cache.db")} ) yield cache cache.close() @@ -116,7 +117,7 @@ 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 "e" not in disk_cache # sqlite 读时惰性淘汰 assert disk_cache.get("e", "miss") == "miss" @@ -130,14 +131,14 @@ def test_touch_disk(disk_cache): def test_context_manager(tmp_path): with Cache.from_config( - {"backend": "diskcache", "cache_store_path": str(tmp_path / "cm")} + {"backend": "sqlite", "path": str(tmp_path / "cm.db")} ) as cache: cache["x"] = 1 assert cache["x"] == 1 def test_from_config_defaults(tmp_path, monkeypatch): - # 缺省 backend 时默认 diskcache;缺省 cache_store_path 时用 ./cache_data + # 缺省 backend 时默认 sqlite;缺省 path 时用 ./cache_data/cache.db monkeypatch.chdir(tmp_path) cache = Cache.from_config({}) try: @@ -149,7 +150,32 @@ def test_from_config_defaults(tmp_path, monkeypatch): def test_invalid_backend(tmp_path): with pytest.raises(ValueError): - Cache.from_config({"backend": "memcached", "cache_store_path": str(tmp_path)}) + Cache.from_config({"backend": "memcached", "path": str(tmp_path)}) + + +def test_sqlite_json_interop_and_multi_instance(tmp_path): + """SQLite 落盘是纯 JSON 文本(Rust 可读),且两个后端实例共享同一文件。""" + db = str(tmp_path / "shared.db") + a = Cache.from_config({"backend": "sqlite", "path": db}) + b = Cache.from_config({"backend": "sqlite", "path": db}) + try: + # 待共享值:纯 JSON 类型,应原样以 JSON 文本落盘 + a.set("app:latest", {"msg": "hello", "n": 42}) + # 共享文件可跨实例读取(多进程安全语义) + assert b.get("app:latest") == {"msg": "hello", "n": 42} + # 直接查库:v 列为 JSON 文本而非 pickle 字节 + row = sqlite3.connect(db).execute( + "SELECT v FROM cache WHERE k='app:latest'" + ).fetchone() + assert row is not None + assert row[0] == '{"msg":"hello","n":42}' + # TTL 过期后跨实例同样读不到 + a.set("exp", 1, expire=1) + time.sleep(1.1) + assert b.get("exp") is None + finally: + a.close() + b.close() # ── redis 后端 ────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 5ed5cb9..ea1846a 100644 --- a/uv.lock +++ b/uv.lock @@ -187,10 +187,11 @@ wheels = [ [[package]] name = "chongming-cache" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "diskcache" }, + { name = "orjson" }, { name = "redis" }, ] @@ -204,6 +205,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "diskcache", specifier = ">=5.6.3" }, + { name = "orjson", specifier = ">=3.9.0" }, { name = "redis", specifier = ">=5.0.0" }, ] @@ -445,6 +447,58 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f9/70/e140dffff6e808dc6343598df76e7e2407fd0f581de3524c75fba2e0cf24/nh3-0.3.7-cp38-abi3-win_arm64.whl", hash = "sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a", size = 621867, upload-time = "2026-08-23T14:26:29.547Z" }, ] +[[package]] +name = "orjson" +version = "3.12.0" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" } +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +] + [[package]] name = "packaging" version = "26.3"