diff --git a/main.py b/main.py index cd942d9..410bb81 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,166 @@ +#!/usr/bin/env python3 +"""端到端冒烟示例:把"打包 → 校验 → 快照 → 发布 → 取回"跑一遍。 + +直接运行即可(全部在临时目录里操作,不碰仓库里的任何文件): + + python main.py + +原来的版本拿仓库自带的 appcation.svfs 做演示,但那个库是**旧格式**(schema 1), +当前版本会直接拒绝并提示用 `vfsmigrate` 迁移——所以示例改成自给自足的一棵树, +走到哪儿都能跑通。 + +要了解完整能力看 README.md;要了解踩过的坑与取舍看 BUG_REPORT.md。 +""" + +import contextlib +import io +import os +import shutil +import sys +import tempfile + +from sqlite_vfs import publish from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker -vfs = SQLiteVFS("appcation.svfs") -print(vfs.get_file_info(".gitignore")) -for item in vfs.list_directory("/"): - print(f"{'DIR' if item['is_directory'] else 'FILE'}: {item['path']}") -vfs.close() +def step(text): + print(f"\n=== {text} ===") + + +@contextlib.contextmanager +def quiet(): + """屏蔽打包过程的逐文件输出,让演示保持可读。""" + with contextlib.redirect_stdout(io.StringIO()): + yield + + +def write_file(root, rel_path, data): + target = os.path.join(root, *rel_path.split("/")) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as f: + f.write(data) + return target + + +def read_tree(root): + out = {} + for base, _dirs, files in os.walk(root): + for name in files: + path = os.path.join(base, name) + with open(path, "rb") as f: + out[os.path.relpath(path, root).replace(os.sep, "/")] = f.read() + return out + + +def main(): + work = tempfile.mkdtemp(prefix="svfs_demo_") + try: + # ---- 1. 造一棵源目录:含两份内容完全相同的文件,用来展示去重 ---- + src = os.path.join(work, "dist") + write_file(src, "index.html", b"demo" * 8) + write_file(src, "app.js", b"console.log('hello');\n" * 40) + write_file(src, "assets/logo.svg", b"" * 50) + write_file(src, "assets/vendor.js", b"export const V = 1;\n" * 60) + write_file(src, "admin/vendor.js", b"export const V = 1;\n" * 60) + os.makedirs(os.path.join(src, "empty_dir"), exist_ok=True) + + # ---- 2. 打包(zlib 压缩 + 内容寻址去重)---- + step("打包:整个文件夹 → 单个 .svfs") + db = os.path.join(work, "app.svfs") + vfs = SQLiteVFS(db, compress=True) + with quiet(): + FolderPacker(vfs, fs_name="demo").pack_folder(src) + vfs.close() + + vfs = SQLiteVFS(db, readonly=True) + stats = vfs.get_stats() + blobs = vfs.list_blobs() + unique_bytes = sum(b["size"] for b in blobs) + stored_bytes = sum(b["stored_size"] for b in blobs) + print(f" 包大小: {os.path.getsize(db) / 1024:.1f} KB") + print(f" 文件数: {stats['total_files']} 原始总大小: {stats['total_size']} 字节") + print( + f" 去重后唯一内容: {len(blobs)} 份 / {unique_bytes} 字节" + f"(两份 vendor.js 共用一份,省 {stats['total_size'] - unique_bytes} 字节)" + ) + print(f" 压缩后库内实际占用: {stored_bytes} 字节") + vfs.close() + + # ---- 3. 完整性校验 + 签名(只读即可验证)---- + step("完整性校验与签名") + vfs = SQLiteVFS(db) + report = vfs.verify(check_content=True) + print( + f" 校验通过: {report['ok']}" + f"(逐文件重算 sha256:{report['content_checked']} 个)" + ) + print(f" 快照摘要: {report['actual_digest'][:32]}…") + vfs.sign("demo-key", key_id="demo") + vfs.close() + + readonly = SQLiteVFS(db, readonly=True) + print(f" 只读连接上验签: {readonly.verify_signature('demo-key')['ok']}") + readonly.close() + + # ---- 4. 快照:同一路径存两个版本 ---- + step("版本历史(快照)") + vfs = SQLiteVFS(db, compress=True) + parent = vfs._active_snapshot() + vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) + vfs.remove("app.js") + new_app = write_file(work, "app-v2.js", b"console.log('v2');\n" * 40) + with quiet(): + vfs.add_file(new_app, "app.js") + vfs.recount() + print(f" v2 里的 app.js: {vfs.read_file('app.js')[:24]!r}") + vfs.use_snapshot(parent) + print(f" v1 里的 app.js: {vfs.read_file('app.js')[:24]!r}") + for snapshot in vfs.list_snapshots(): + print( + f" 快照 {snapshot['id']} {snapshot['name']}: " + f"{snapshot['total_files']} 个文件,摘要 {snapshot['digest'][:12]}…" + ) + vfs.close() + + # ---- 5. 发布成"按 hash 取对象 + 清单" ---- + step("发布到对象存储(对象名 = 内容 sha256)") + objects = os.path.join(work, "public") + # 注意默认发布**最新**快照(此时是 v2,app.js 已被替换)。这里显式指定 v1, + # 因为第 7 步要拿它和源目录比对;要发布 v2 就去掉 snapshot_id。 + result = publish.export_objects(db, objects, snapshot_id=parent) + print(f" 发布快照: {result['snapshot_id']}(默认会发布最新的那个)") + print(f" 对象: {result['objects']} 个,共 {result['objects_bytes']} 字节") + print( + f" 清单: {os.path.basename(result['manifest'])}" + f"({result['manifest_bytes']} 字节,含全部路径、大小与 sha256)" + ) + + # ---- 6. 从"对象 + 清单"取回,并逐个校验 sha256 ---- + step("取回并逐个校验 sha256") + manifest = publish.load_manifest(result["manifest"]) + restored = os.path.join(work, "restored") + fetch_stats = publish.ObjectFetcher( + manifest, local_root=objects, cache_dir=os.path.join(work, "cache") + ).fetch(restored) + print( + f" 取回 {fetch_stats.files} 个文件," + f"校验 {fetch_stats.verified} 个对象" + ) + + # ---- 7. 逐字节比对 ---- + step("与源目录逐字节比对") + if read_tree(src) == read_tree(restored): + print(" 一致 ✓") + else: + print(" 不一致 ✗") + return 1 + + print(f"\n全部演示完成(文件都在临时目录里,退出时删除)") + return 0 + finally: + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 5f38347..f6596bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,17 +3,30 @@ dependencies = [] description = "sqlite virtual file system" name = "sqlite-vfs" readme = "README.md" -requires-python = ">=3.7" +# 版本门槛按实际用到的 API 推导,不是拍的: +# * contrib/fastapi_static.py 用了 hashlib.md5(..., usedforsecurity=False) → 3.9+ +# * 测试用了 random.Random.randbytes → 3.9+ +# 核心(core/compression/migration/publish/folder_*)本身只用到 3.7 就有的东西; +# 仓库里没有 3.10+ 的语法(无 match、无 PEP 604 注解、无 zip(strict=))。 +# 说明:这里只在本机的 3.13 上跑过,"3.9 可用"是代码审查的结论,没有在 3.9 上实测。 +requires-python = ">=3.9" version = "0.1.0" +# 可选集成:核心功能只用标准库,第三方框架一律走 extras。 +# 例如 pip install "sqlite-vfs[fastapi]" 才会装上 FastAPI/Starlette。 +[project.optional-dependencies] +fastapi = [ + "fastapi>=0.100", + "starlette>=0.27", +] + [[tool.uv.index]] default = true url = "https://pypi.tuna.tsinghua.edu.cn/simple" -[tool.uv.workspace] -members = [ - "devmo", -] +# 说明:这里原先有 [tool.uv.workspace] members = ["devmo"],但仓库里没有 devmo 目录。 +# 实测 uv 0.11.8 容忍这种写法(uv sync --dry-run 退出码 0),所以它不是阻塞项, +# 只是会误导人,因此删掉。 [build-system] build-backend = "hatchling.build" @@ -27,6 +40,10 @@ dev = [ [project.scripts] vfspack = "sqlite_vfs.cli.packer:pack" vfsunpack = "sqlite_vfs.cli.unpacker:unpack" +vfsverify = "sqlite_vfs.cli.verify:verify" +vfsmigrate = "sqlite_vfs.cli.migrate:main" +vfspublish = "sqlite_vfs.cli.publish:publish_objects" +vfsfetch = "sqlite_vfs.cli.fetch:fetch_objects" [tool.hatch.build.targets.wheel] packages = ["src/sqlite_vfs"] diff --git a/src/sqlite_vfs/cli/fetch.py b/src/sqlite_vfs/cli/fetch.py new file mode 100644 index 0000000..453a77b --- /dev/null +++ b/src/sqlite_vfs/cli/fetch.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""vfsfetch:从"对象 + 清单"形态取回文件。 + +清单可以是本地路径,也可以是 URL(对象默认从清单同级目录取)。 +每个对象都按名字里的 sha256 校验,所以传输被改动或服务端内容不符都会被拒绝, +而且内容不可变,缓存可以长期复用(默认缓存目录见 --help)。 +""" + +import argparse +import os +import sys +import time + +from .. import publish + + +def fetch_objects(): + parser = argparse.ArgumentParser( + description="按清单从对象存储取回文件(内容按 sha256 校验)" + ) + parser.add_argument( + "manifest", + help="清单路径或 URL(如 https://cdn.example.com/app/manifest.json)", + ) + parser.add_argument("out_dir", help="把文件铺到哪个目录") + parser.add_argument( + "--path", + action="append", + metavar="P", + help="只取指定文件或目录前缀,可重复(默认全部)", + ) + parser.add_argument( + "--base-url", + help="对象所在的基地址;清单是本地文件时用它指向 CDN", + ) + parser.add_argument( + "--cache", + metavar="DIR", + default=publish.default_cache_dir(), + help=f"对象缓存目录(默认 {publish.default_cache_dir()})", + ) + parser.add_argument("--no-cache", action="store_true", help="不使用缓存") + parser.add_argument( + "--workers", + type=int, + default=publish.DEFAULT_WORKERS, + help=f"并发下载数(默认 {publish.DEFAULT_WORKERS})", + ) + parser.add_argument( + "--link", + action="store_true", + help="用硬链接落盘省空间(注意:不还原时间戳/权限,且文件与缓存共用 inode)", + ) + parser.add_argument( + "--no-verify", + action="store_true", + help="跳过 sha256 校验(默认校验,不推荐关闭)", + ) + parser.add_argument("--json", action="store_true", help="以 JSON 输出统计") + + args = parser.parse_args() + + try: + manifest = publish.load_manifest(args.manifest) + except FileNotFoundError: + print(f"错误: 清单不存在: {args.manifest}") + sys.exit(1) + except Exception as e: + print(f"错误: 无法读取清单: {e}") + sys.exit(1) + + base_url = args.base_url + local_root = None + if base_url is None: + if publish._is_url(args.manifest): + # 清单来自 URL:对象默认在同一个目录下 + base_url = args.manifest.rsplit("/", 1)[0] + else: + # 清单是本地文件:默认从它所在目录读对象 + local_root = os.path.dirname(os.path.abspath(args.manifest)) + + fetcher = publish.ObjectFetcher( + manifest, + base_url=base_url, + local_root=local_root, + cache_dir=None if args.no_cache else args.cache, + workers=args.workers, + verify=not args.no_verify, + link=args.link, + ) + + started = time.time() + try: + stats = fetcher.fetch(args.out_dir, paths=args.path) + except FileNotFoundError as e: + print(f"错误: {e}") + sys.exit(1) + except ValueError as e: + print(f"错误: {e}") + sys.exit(1) + except Exception as e: + print(f"错误: 下载失败: {e}") + sys.exit(1) + elapsed = time.time() - started + + if args.json: + import json + + print(json.dumps(stats.as_dict(), ensure_ascii=False, indent=2)) + return + + snapshot = manifest.get("snapshot", {}) + print(f"包: {manifest.get('name')} 快照: {snapshot.get('id')} ({snapshot.get('name')})") + print(f" 文件: {stats.files} 目录: {stats.directories}") + print( + f" 对象: 下载 {stats.objects_downloaded},缓存命中 {stats.objects_from_cache}" + f",校验 {stats.verified}" + ) + print(f" 下载字节: {stats.bytes_downloaded / 1024:.1f} KB") + print(f" 耗时: {elapsed:.2f} 秒") + print(f" 输出: {os.path.abspath(args.out_dir)}") + + +if __name__ == "__main__": + fetch_objects() diff --git a/src/sqlite_vfs/cli/migrate.py b/src/sqlite_vfs/cli/migrate.py new file mode 100644 index 0000000..b0be3bf --- /dev/null +++ b/src/sqlite_vfs/cli/migrate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""vfsmigrate:把旧格式(v1 / v2)的 .svfs 迁移到当前格式(v3)。 + +默认原地迁移并留一份 .bak 备份;用 --output 可以输出到新文件, +适合原库位于只读介质、或想先比对再切换的场景。 +""" + +import argparse +import os +import sys + +from .. import migration +from ..core import SCHEMA_VERSION + + +def main(): + parser = argparse.ArgumentParser( + description=f"把旧格式的虚拟文件系统迁移到 schema v{SCHEMA_VERSION}" + ) + parser.add_argument("db_file", help="要迁移的数据库文件路径") + parser.add_argument( + "--output", + metavar="PATH", + help="输出到新文件而不是原地替换(原库保持不动)", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="原地迁移时不生成 .bak 备份", + ) + parser.add_argument("--name", help="迁移后默认快照的名字(默认沿用库里的名称)") + + args = parser.parse_args() + + if not os.path.exists(args.db_file): + print(f"错误: 数据库文件不存在: {args.db_file}") + sys.exit(1) + + try: + version = migration.detect_schema_version(args.db_file) + except Exception as e: + print(f"错误: 无法读取 {args.db_file}: {e}") + sys.exit(1) + + if version is None: + print(f"错误: {args.db_file} 不是虚拟文件系统数据库") + sys.exit(1) + if version == SCHEMA_VERSION: + print(f"无需迁移: 已经是 schema v{SCHEMA_VERSION}") + return + + total = [0] + + def progress(done, total_count): + total[0] = total_count + if total_count and (done == total_count or done % 500 == 0): + print(f" 迁移进度: {done}/{total_count}", flush=True) + + print(f"开始迁移: {args.db_file}(schema v{version} → v{SCHEMA_VERSION})") + try: + result = migration.migrate( + args.db_file, + backup=None if not args.no_backup else False, + snapshot_name=args.name, + progress=progress, + output=args.output, + ) + except FileExistsError as e: + print(f"错误: {e}") + sys.exit(1) + except Exception as e: + print(f"错误: 迁移失败,原库未被改动: {e}") + sys.exit(1) + + print("迁移完成!") + print(f" 条目数: {result['files']},去重后 blob 数: {result['blobs']}") + if result["dedup_saved"]: + print(f" 去重省下: {result['dedup_saved'] / 1024:.1f} KB") + print(f" 快照摘要: {result['digest']}") + print(f" 结果写入: {result['output']}") + if result["backup"]: + print(f" 备份保留: {result['backup']}") + print(f" 可以用 `vfsverify \"{result['output']}\" --check-content` 复核内容") + + +if __name__ == "__main__": + main() diff --git a/src/sqlite_vfs/cli/packer.py b/src/sqlite_vfs/cli/packer.py index 182c9b6..620baba 100644 --- a/src/sqlite_vfs/cli/packer.py +++ b/src/sqlite_vfs/cli/packer.py @@ -4,9 +4,12 @@ """ import os +import sqlite3 +import sys import argparse +from .. import compression from ..folder_packer import FolderPacker -from ..core import SQLiteVFS +from ..core import SQLiteVFS, VFSFormatError def pack(): @@ -15,26 +18,99 @@ def pack(): parser.add_argument("output_db", help="输出的SQLite数据库文件路径") parser.add_argument("--name", default="packed_filesystem", help="文件系统名称") parser.add_argument("--compress", action="store_true", help="启用压缩") + parser.add_argument( + "--compress-algo", + choices=list(compression.ALGORITHMS)[1:], # 排除 'none',它由 --compress 控制 + default=compression.DEFAULT_ALGORITHM, + help=f"压缩算法(默认 {compression.DEFAULT_ALGORITHM})", + ) parser.add_argument("--exclude", nargs="*", help="要排除的文件模式") + parser.add_argument( + "--incremental", + action="store_true", + help="只写入变化的条目(默认按 mtime+size 判定)", + ) + parser.add_argument( + "--rehash", + action="store_true", + help="增量打包时按 sha256 逐文件比对(更可靠,但要把文件都读一遍)", + ) + parser.add_argument( + "--snapshot", + metavar="NAME", + help="把这次的改动打包成一个新快照(从当前快照派生),实现版本历史", + ) args = parser.parse_args() if not os.path.exists(args.source_folder): print(f"错误: 源文件夹不存在: {args.source_folder}") - return + sys.exit(1) + if not os.path.isdir(args.source_folder): + print(f"错误: 源路径不是文件夹: {args.source_folder}") + sys.exit(1) + + output_dir = os.path.dirname(os.path.abspath(args.output_db)) + if not os.path.isdir(output_dir): + print(f"错误: 输出目录不存在: {output_dir}") + sys.exit(1) + + if os.path.exists(args.output_db): + if args.incremental or args.snapshot: + print(f"在已有库上追加(增量/新快照): {args.output_db}") + else: + print(f"警告: 输出文件已存在,其内容将被清空后重写: {args.output_db}") + elif args.incremental or args.snapshot: + print(f"目标库不存在,将新建: {args.output_db}") + + try: + sqlite_vfs = SQLiteVFS( + args.output_db, + compress=args.compress, + compress_algorithm=args.compress_algo, + ) + except (VFSFormatError, sqlite3.Error) as e: + print(f"错误: 无法使用输出数据库 {args.output_db}: {e}") + sys.exit(1) # 创建打包器 packer = FolderPacker( - sqlite_vfs=SQLiteVFS(args.output_db, args.compress), + sqlite_vfs=sqlite_vfs, exclude_patterns=args.exclude or [], fs_name=args.name, ) try: - packer.pack_folder(args.source_folder) + if args.snapshot: + try: + parent = sqlite_vfs._active_snapshot() + snapshot_id = sqlite_vfs.create_snapshot( + args.snapshot, + parent_id=parent, + copy_entries=parent is not None, + ) + except Exception as e: + print(f"错误: 无法创建快照 {args.snapshot}: {e}") + sys.exit(1) + print( + f"已创建快照 {args.snapshot}(id={snapshot_id}" + + (f",派生自 {parent}" if parent else ",库内首个快照") + + ")" + ) + # 派生出来的快照已经是父快照的副本,所以按增量方式更新即可 + success = packer.pack_folder( + args.source_folder, incremental=True, rehash=args.rehash + ) + else: + success = packer.pack_folder( + args.source_folder, incremental=args.incremental, rehash=args.rehash + ) finally: packer.sqlite_vfs.close() + if not success: + sys.exit(1) + if __name__ == "__main__": pack() diff --git a/src/sqlite_vfs/cli/publish.py b/src/sqlite_vfs/cli/publish.py new file mode 100644 index 0000000..6a8fad4 --- /dev/null +++ b/src/sqlite_vfs/cli/publish.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""vfspublish:把 .svfs 发布成"一堆按 hash 命名的对象 + 一份清单"。 + +产出可以直接 `aws s3 sync` / `rclone copy` 到对象存储或静态托管: +对象名就是内容的 sha256,所以客户端的缓存策略可以是"永久", +而且每个对象都能端到端校验(CDN 换不掉内容)。 +""" + +import argparse +import os +import sys + +from .. import publish +from ..core import VFSFormatError + + +def publish_objects(): + parser = argparse.ArgumentParser( + description="把虚拟文件系统导出成内容寻址的对象目录 + 清单" + ) + parser.add_argument("db_file", help="SQLite数据库文件路径") + parser.add_argument("out_dir", help="输出目录(会创建 blobs/ 与 manifest.json)") + parser.add_argument( + "--snapshot", metavar="ID", help="发布指定快照(默认取最新快照)" + ) + parser.add_argument( + "--shard", + type=int, + default=publish.DEFAULT_SHARD, + help=f"blobs 的分片层数,0 表示不分片(默认 {publish.DEFAULT_SHARD})", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="输出目录非空时也继续(会覆盖同名对象与清单)", + ) + parser.add_argument( + "--no-objects", + action="store_true", + help="只生成清单,不导出对象(用于只更新清单的场景)", + ) + + args = parser.parse_args() + + if not os.path.exists(args.db_file): + print(f"错误: 数据库文件不存在: {args.db_file}") + sys.exit(1) + + done = [0] + + def progress(current, total): + done[0] = current + if total and (current == total or current % 200 == 0): + print(f" 导出对象: {current}/{total}", flush=True) + + try: + if args.no_objects: + from ..core import SQLiteVFS + + vfs = SQLiteVFS(args.db_file, readonly=True, snapshot_id=args.snapshot) + try: + manifest = publish.build_manifest( + vfs, snapshot_id=args.snapshot, shard=args.shard + ) + finally: + vfs.close() + os.makedirs(args.out_dir, exist_ok=True) + import json + + path = os.path.join(args.out_dir, publish.MANIFEST_NAME) + payload = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + with open(path, "w", encoding="utf-8") as f: + f.write(payload) + result = { + "out_dir": os.path.abspath(args.out_dir), + "manifest": path, + "manifest_bytes": len(payload.encode("utf-8")), + "objects": 0, + "objects_bytes": 0, + "files": manifest["stats"]["files"], + "directories": manifest["stats"]["directories"], + "dedup_saved": 0, + "snapshot_id": manifest["snapshot"]["id"], + "digest": manifest["snapshot"]["digest"], + } + else: + result = publish.export_objects( + args.db_file, + args.out_dir, + snapshot_id=args.snapshot, + shard=args.shard, + overwrite=args.overwrite, + progress=progress, + ) + except VFSFormatError as e: + print(f"错误: {e}") + sys.exit(1) + except FileExistsError as e: + print(f"错误: {e}") + sys.exit(1) + except Exception as e: + print(f"错误: 发布失败: {e}") + sys.exit(1) + + print("发布完成!") + print(f" 快照: {result['snapshot_id']} 摘要: {(result['digest'] or '')[:16]}") + print(f" 文件: {result['files']} 目录: {result['directories']}") + print(f" 对象: {result['objects']} 个,共 {result['objects_bytes'] / 1024:.1f} KB(去重后的原始字节)") + if result["dedup_saved"]: + print(f" 去重省下: {result['dedup_saved'] / 1024:.1f} KB(原始总大小 - 去重后)") + if result.get("compression_saved"): + print( + f" 库内实际占用: {result['stored_bytes'] / 1024:.1f} KB" + f"(压缩省下 {result['compression_saved'] / 1024:.1f} KB)" + ) + print(f" 清单: {result['manifest']}({result['manifest_bytes'] / 1024:.1f} KB)") + print(f" 输出目录: {result['out_dir']}") + print(" 下一步: 把整个输出目录同步到对象存储/静态托管即可") + + +if __name__ == "__main__": + publish_objects() diff --git a/src/sqlite_vfs/cli/unpacker.py b/src/sqlite_vfs/cli/unpacker.py index 0b0ed38..00b4e5f 100644 --- a/src/sqlite_vfs/cli/unpacker.py +++ b/src/sqlite_vfs/cli/unpacker.py @@ -4,8 +4,10 @@ """ import os +import sqlite3 +import sys import argparse -from ..core import SQLiteVFS +from ..core import SQLiteVFS, VFSFormatError from ..folder_unpacker import FolderUnpacker @@ -18,6 +20,16 @@ def unpack(): "--list-root", action="store_true", help="列出根目录内容而不导出" ) parser.add_argument("--debug", action="store_true", help="显示调试信息") + parser.add_argument( + "--snapshot", + metavar="ID", + help="指定要导出的快照 id(默认取最新快照)", + ) + parser.add_argument( + "--list-snapshots", + action="store_true", + help="列出库里所有快照而不导出", + ) parser.add_argument( "--ignore-timestamps", action="store_true", @@ -28,12 +40,42 @@ def unpack(): if not os.path.exists(args.db_file): print(f"错误: 数据库文件不存在: {args.db_file}") - return - - # 创建解包器 - unpacker = FolderUnpacker(sqlite_vfs=SQLiteVFS(args.db_file)) + sys.exit(1) + if not os.path.isfile(args.db_file): + print(f"错误: 数据库路径不是文件: {args.db_file}") + sys.exit(1) try: + sqlite_vfs = SQLiteVFS(args.db_file, snapshot_id=args.snapshot) + except VFSFormatError as e: + print(f"错误: {e}") + sys.exit(1) + except FileNotFoundError as e: + print(f"错误: {e}") + sys.exit(1) + except (sqlite3.Error, ValueError) as e: + print(f"错误: 无法打开数据库 {args.db_file}: {e}") + sys.exit(1) + + # 创建解包器 + unpacker = FolderUnpacker(sqlite_vfs=sqlite_vfs) + + try: + if args.list_snapshots: + snapshots = unpacker.list_snapshots() + if not snapshots: + print("库里没有任何快照") + return + print(f"{'ID':>4s} {'名称':<20s} {'文件数':>6s} {'总大小':>12s} 摘要") + for snapshot in snapshots: + digest = snapshot["digest"] or "-" + print( + f"{snapshot['id']:>4d} {snapshot['name']:<20s} " + f"{snapshot['total_files'] or 0:>6d} " + f"{snapshot['total_size'] or 0:>12d} {digest[:16]}" + ) + return + if args.list_root: # 只列出根目录内容 print("根目录内容:") @@ -45,29 +87,34 @@ def unpack(): if args.path: # 导出特定路径 - if unpacker.sqlite_vfs.is_dir(args.path): + if not sqlite_vfs.exists(args.path): + print(f"错误: 虚拟文件系统中不存在该路径: {args.path}") + sys.exit(1) + + if sqlite_vfs.is_dir(args.path): local_dir = os.path.join( args.export_folder, os.path.basename(args.path) ) - unpacker.unpack_directory(args.path, local_dir) + unpacker.unpack_directory( + args.path, local_dir, ignore_timestamps=args.ignore_timestamps + ) else: local_file = os.path.join( args.export_folder, os.path.basename(args.path) ) if args.ignore_timestamps: - unpacker.sqlite_vfs.export_file_safe(args.path, local_file) + sqlite_vfs.export_file_safe(args.path, local_file) else: unpacker.unpack_file(args.path, local_file) else: # 导出整个文件系统 - if args.ignore_timestamps: - # 使用安全导出方法 - unpacker.sqlite_vfs.export_filesystem = ( - lambda export_root: unpacker.sqlite_vfs.export_filesystem( - export_root - ) - ) - unpacker.unpack_filesystem(args.export_folder) + unpacker.unpack_filesystem( + args.export_folder, ignore_timestamps=args.ignore_timestamps + ) + + except RuntimeError as e: + print(f"错误: {e}") + sys.exit(1) finally: unpacker.sqlite_vfs.close() diff --git a/src/sqlite_vfs/cli/verify.py b/src/sqlite_vfs/cli/verify.py new file mode 100644 index 0000000..586a257 --- /dev/null +++ b/src/sqlite_vfs/cli/verify.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""vfsverify:校验 .svfs 的完整性、查看快照、签名与验签。 + +只读操作(除非显式 --sign),所以可以直接对着只读介质上的资源包跑。 +""" + +import argparse +import json +import os +import sys + +from ..core import SQLiteVFS, VFSFormatError + + +def _print_snapshots(vfs): + snapshots = vfs.list_snapshots() + if not snapshots: + print("库里没有任何快照") + return + print(f"{'ID':>4s} {'名称':<24s} {'文件数':>6s} {'总大小':>12s} 摘要") + print("-" * 78) + for snapshot in snapshots: + digest = snapshot["digest"] or "-" + marker = " *" if snapshot["id"] == vfs._active_snapshot() else " " + print( + f"{snapshot['id']:>4d}{marker}{snapshot['name']:<24s} " + f"{snapshot['total_files'] or 0:>6d} " + f"{snapshot['total_size'] or 0:>12d} {digest[:32]}" + ) + print("(* 表示默认快照,即未指定 --snapshot 时使用的那个)") + + +def verify(): + parser = argparse.ArgumentParser( + description="校验虚拟文件系统的完整性(摘要 / 内容哈希 / 签名)" + ) + parser.add_argument("db_file", help="SQLite数据库文件路径") + parser.add_argument( + "--snapshot", metavar="ID", help="指定要校验的快照(默认取最新快照)" + ) + parser.add_argument( + "--list-snapshots", action="store_true", help="只列出快照与摘要" + ) + parser.add_argument( + "--check-content", + action="store_true", + help="逐文件重算 sha256(权威校验,代价是读一遍全部内容)", + ) + parser.add_argument("--key", help="HMAC 密钥:给出时顺便验签(配合 --sign 则用于签名)") + parser.add_argument("--key-id", help="密钥标识,便于同一快照有多个签名") + parser.add_argument( + "--sign", + action="store_true", + help="用 --key 给当前快照的摘要签名(这是唯一会写库的操作)", + ) + parser.add_argument( + "--json", action="store_true", help="以 JSON 输出结果,便于交给外部工具处理" + ) + + args = parser.parse_args() + + if not os.path.exists(args.db_file): + print(f"错误: 数据库文件不存在: {args.db_file}") + sys.exit(1) + + readonly = not args.sign + try: + vfs = SQLiteVFS(args.db_file, readonly=readonly, snapshot_id=args.snapshot) + except VFSFormatError as e: + print(f"错误: {e}") + sys.exit(1) + except Exception as e: + print(f"错误: 无法打开数据库 {args.db_file}: {e}") + sys.exit(1) + + exit_code = 0 + try: + if args.list_snapshots: + _print_snapshots(vfs) + return + + report = vfs.verify(check_content=args.check_content) + signature_report = None + if args.key: + if args.sign: + signature = vfs.sign(args.key, key_id=args.key_id) + print(f"已签名(hmac-sha256{', key_id=' + args.key_id if args.key_id else ''}): {signature}") + signature_report = vfs.verify_signature(args.key, key_id=args.key_id) + + if args.json: + payload = dict(report) + payload["signature"] = ( + {k: v for k, v in signature_report.items() if k != "expected"} + if signature_report + else None + ) + print(json.dumps(payload, ensure_ascii=False, indent=2, default=str)) + else: + print(f"快照: {report['snapshot_id']}") + print(f"摘要: {report['actual_digest']}") + if report["expected_digest"]: + print(f"记录: {report['expected_digest']}") + print( + f"文件数: {report['files_checked']}" + + ( + f"(其中 {report['content_checked']} 个做了内容哈希校验)" + if report["content_checked"] + else "(未做内容哈希校验,加 --check-content 可做权威校验)" + ) + ) + if signature_report is not None: + if signature_report["ok"]: + print("签名: OK") + else: + print(f"签名: 失败 —— {'; '.join(signature_report['errors'])}") + if report["ok"]: + print("结论: 完整性校验通过") + else: + print("结论: 校验失败") + for error in report["errors"]: + print(f" - {error}") + + if not report["ok"]: + exit_code = 1 + if signature_report is not None and not signature_report["ok"]: + exit_code = 1 + finally: + vfs.close() + + if exit_code: + sys.exit(exit_code) + + +if __name__ == "__main__": + verify() diff --git a/src/sqlite_vfs/compression.py b/src/sqlite_vfs/compression.py new file mode 100644 index 0000000..97813af --- /dev/null +++ b/src/sqlite_vfs/compression.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""文件内容的压缩与解压:算法分发的**唯一扩展点**。 + +`core.py` 不关心具体算法,它只做两件事:把 `blobs.compress_algo` 的值传进来, +以及把返回的 `aux` 存进 `blobs.freq_blob`。所以加算法只需要动这一个文件。 + +怎么加一个算法 +-------------- +1. 往 ``ALGORITHMS`` 里加名字; +2. 在 ``compress_with`` / ``decompress_with`` 里各加一个分支; +3. 若它有自己的最小体积门槛,加到 ``core.SQLiteVFS.COMPRESS_MIN_SIZE``; +4. 在 ``tests/test_compression.py`` 的往返用例里覆盖到。 + +**`aux` 是通用的"每块附加数据"槽**,不是哈夫曼专用:它现在是频率表, +将来也可以是加密用的 nonce + 认证标签。这一点对后面加加密很重要—— +`blobs` 表已经有 `freq_blob BLOB` 这个位置,不必为了加密改 schema。 + +算法契约 +-------- +* ``compress_with(data, algo) -> (algo, aux_or_None, payload)`` + —— 返回的 ``algo`` 会被原样写进 `compress_algo`,`decompress_with` 凭它分发。 +* ``decompress_with(algo, aux, payload) -> bytes`` + —— 必须能还原 ``compress_with`` 的输入;``aux`` 可能是 None。 +* 实现要不要"压得动才采用"由调用方判断(core 用 `stored_size` 比大小), + 算法本身只管正确地产出。 + +算法取舍(实测数据见 README,语料为 733 KB 前端构建产物) +-------------------------------------------------------- +默认用 **zlib**(标准库,C 实现): + +* 压缩比更好:同一份文本,zlib 得到的体积约为哈夫曼的 1/3~1/170 + (deflate 有 LZ77 滑动窗口,能吃掉"重复";纯哈夫曼只有熵编码,吃不到); +* 快得多:640 KB 数据压缩 4 ms、解压 1.2 ms(约 300~600 MB/s), + 哈夫曼是纯 Python 位操作,压缩 649 ms、解压 772 ms(约 0.5~0.8 MB/s); +* 小文件无额外开销:zlib 的包头只有几字节,而哈夫曼每个文件要额外存 1024 字节频率表。 + +**huffman** 是自研算法,保留它有两个理由:读得懂早期版本打的包(那些库里 +`compress_algo='huffman'`),以及作为自定义算法的起点。它的定位是"可选/实验", +默认算法不要改回它——`tests/test_compression.py` 里有专门用例钉住这个数量级差距。 + +要加加密时的注意点见 ``docs/encryption-design.md``(去重与随机 nonce 的冲突、 +标准库没有密码学原语这两件事必须先决定)。 +""" + +import zlib + +from .huffman import compress_bytes as _huffman_compress +from .huffman import decompress_bytes as _huffman_decompress + +#: 支持的全部算法;'none' 表示原样存储 +ALGORITHMS = ("none", "zlib", "huffman") + +DEFAULT_ALGORITHM = "zlib" + +#: zlib 压缩级别:6 是"压缩比 / 速度"的平衡点 +ZLIB_LEVEL = 6 + + +def compress_with(data, algorithm=DEFAULT_ALGORITHM): + """压缩数据。 + + 返回 ``(algorithm, freq_blob, blob)``: + * 'none' -> (\"none\", None, 原始数据) + * 'zlib' -> (\"zlib\", None, zlib 流) + * 'huffman' -> (\"huffman\", 1024 字节频率表, 位打包数据) + """ + if algorithm == "none": + return "none", None, data + if algorithm == "zlib": + return "zlib", None, zlib.compress(data, ZLIB_LEVEL) + if algorithm == "huffman": + freq_blob, blob = _huffman_compress(data) + return "huffman", freq_blob, blob + raise ValueError( + f"未知压缩算法: {algorithm!r};可用: {', '.join(ALGORITHMS)}" + ) + + +def decompress_with(algorithm, freq_blob, blob): + """按库内记录的算法还原数据。 + + 兼容早期格式:旧版本的 `compressed` 列用 zlib,迁移时把它映射成 + 'zlib' 即可读出(见 README「旧格式迁移」)。 + """ + algorithm = algorithm or "none" + + if algorithm == "none": + return blob + if algorithm == "zlib": + return zlib.decompress(blob) + if algorithm == "huffman": + if freq_blob is None: + raise ValueError("压缩算法是 huffman 但缺少频率表(freq_blob 为 NULL)") + return _huffman_decompress(freq_blob, blob) + + raise ValueError( + f"未知压缩算法: {algorithm!r};当前版本支持: {', '.join(ALGORITHMS)}" + ) + + +def stored_size(blob, freq_blob): + """数据实际占用的字节数(含哈夫曼的频率表)。""" + return len(blob) + (len(freq_blob) if freq_blob else 0) diff --git a/src/sqlite_vfs/contrib/__init__.py b/src/sqlite_vfs/contrib/__init__.py new file mode 100644 index 0000000..21fbf65 --- /dev/null +++ b/src/sqlite_vfs/contrib/__init__.py @@ -0,0 +1,16 @@ +"""可选集成组件。 + +这个子包里的模块都**不是** sqlite_vfs 的核心功能,它们依赖第三方库, +因此: + +* 这些第三方库被声明为可选依赖(extras),不在 ``[project].dependencies`` 里; +* ``import sqlite_vfs`` 不会导入本子包的任何模块,缺少第三方库时一切照常工作; +* 只有真的导入某个插件模块时,才会要求对应的第三方库存在, + 并给出可执行的安装提示。 + +当前提供: + +``sqlite_vfs.contrib.fastapi_static`` + 把 .svfs 当作 FastAPI / Starlette 的静态文件目录来服务。 + 可选依赖:``pip install "sqlite-vfs[fastapi]"`` +""" diff --git a/src/sqlite_vfs/contrib/fastapi_static.py b/src/sqlite_vfs/contrib/fastapi_static.py new file mode 100644 index 0000000..4b286c9 --- /dev/null +++ b/src/sqlite_vfs/contrib/fastapi_static.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""把 .svfs 当作 FastAPI / Starlette 的静态文件目录来服务。 + +这是一个**可选插件**:fastapi / starlette 不在 sqlite_vfs 的必需依赖里, +`import sqlite_vfs` 与 `import sqlite_vfs.core` 都不会连带导入它们, +只有真的导入本模块时才会要求它们存在。 + +安装可选依赖: + + pip install "sqlite-vfs[fastapi]" + +生成静态资源包并挂载: + + vfspack ./dist static.svfs --compress --exclude "*.map" + + from fastapi import FastAPI + from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles + + app = FastAPI() + app.mount("/", SVFSStaticFiles("static.svfs"), name="static") + +设计说明:与 StaticFiles 的关系 +-------------------------------- +静态内容存在数据库里,而 StaticFiles 的响应链路(FileResponse)只认磁盘路径, +所以这里复用上游的**路由决策**、替换底层的**字节来源**: + +* 重写 ``lookup_path``:把 URL 路径映射成库内路径,并合成一个 ``os.stat_result``; + 上游的 405 / 目录 index.html / 目录尾斜杠重定向 / 404.html 逻辑因此原样可用。 +* 重写 ``file_response``:从库内读字节并构造响应,顺带实现上游 FileResponse + 具备的 ETag / Last-Modified / Range / 304 条件请求。 +* 只在 ``get_response`` 上加一层薄壳,用来实现 SPA 路由回退。 + +线程模型:SQLite 连接不是线程安全的,这里用一把可重入锁串行化访问 +(``check_same_thread=False`` 由本模块自己负责串行化)。字节读取放在线程池里执行, +事件循环不会被"读 BLOB + 哈夫曼解压"这种同步 CPU 工作卡住。 + +与 StaticFiles 的两处默认值差异(都是为了"直接服务一个前端构建产物"这个场景): +``html=True`` 与 ``spa=True``。需要严格 404 时显式传 ``html=False, spa=False``。 +""" + +import os +import stat as stat_module +import threading +from datetime import datetime, timezone +from email.utils import format_datetime +from hashlib import md5 + +from sqlite_vfs.core import SQLiteVFS, VFSFormatError +from sqlite_vfs.mime import MIME_OVERRIDES, guess_mime + +try: + from starlette.concurrency import run_in_threadpool + from starlette.datastructures import Headers + from starlette.exceptions import HTTPException + from starlette.responses import Response + from starlette.staticfiles import StaticFiles +except ImportError as exc: # pragma: no cover - 只在缺少可选依赖时触发 + raise ImportError( + "sqlite_vfs.contrib.fastapi_static 需要 FastAPI / Starlette," + '请先安装可选依赖:pip install "sqlite-vfs[fastapi]"' + ) from exc + + +#: 索引文件名,固定与 Starlette StaticFiles 一致 +INDEX_NAME = "index.html" + +class _RangeNotSatisfiable(Exception): + """Range 头语法正确但无法满足(应返回 416)。""" + + +def _mime_type(vfs_path): + """按扩展名推断 Content-Type(覆盖表见 sqlite_vfs.mime)。""" + return guess_mime(vfs_path) + + +def _to_vfs_path(url_path): + """URL 路径 -> 库内路径。 + + 要点: + * Starlette 的 ``StaticFiles.get_path`` 会做 ``os.path.normpath``, + 所以根 URL 传进来的是 ``"."`` 而不是 ``"/"``;Windows 上子路径还会带 ``\\``。 + 这两种形式都必须能映射回库内路径。 + * 只做字符串归一化、不做磁盘路径拼接,因此 ``..`` 之类无法越出数据库: + ``get_file_info`` 只做精确匹配,匹配不到就是 404。 + """ + parts = [ + part + for part in url_path.replace("\\", "/").split("/") + if part and part != "." + ] + return "/".join(parts) + + +def _timestamp(value): + """把库内的时间字段转成 epoch 秒(用于 Last-Modified / ETag)。""" + if value is None: + return 0.0 + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return 0.0 + try: + return value.timestamp() + except (AttributeError, OSError, ValueError): + return 0.0 + + +def _make_stat_result(mode, size, mtime): + """构造 os.stat_result。 + + os.stat_result 要求恰好 10 个元素,顺序见 stat 模块文档; + inode/dev/uid/gid 对虚拟文件系统没有意义,填 0、nlink 填 1 即可。 + """ + return os.stat_result((mode, 0, 0, 1, 0, 0, size, mtime, mtime, mtime)) + + +def _to_stat_result(info): + """用库内元数据合成 stat,让上游的 stat.S_ISREG / S_ISDIR 判断可用。 + + info 为 None 表示"虚拟文件系统的根目录",它永远作为目录存在。 + """ + if info is None: + return _make_stat_result(stat_module.S_IFDIR, 0, 0.0) + type_bits = stat_module.S_IFDIR if info["is_directory"] else stat_module.S_IFREG + return _make_stat_result( + type_bits | (info["permissions"] or 0), + info["file_size"] or 0, + _timestamp(info["modified_time"]), + ) + + +def _etag_for(stat_result): + """与 Starlette FileResponse 相同的 ETag 生成方式,便于和生态里的缓存策略对齐。""" + base = f"{stat_result.st_mtime}-{stat_result.st_size}" + return f'"{md5(base.encode(), usedforsecurity=False).hexdigest()}"' + + +def _wants_html(scope): + """请求方是否接受 HTML(浏览器导航请求带 Accept: text/html)。""" + accept = Headers(scope=scope).get("accept", "") + if not accept: + return True + return "text/html" in accept or "*/*" in accept + + +def _looks_like_navigation(url_path): + """最后一段路径不含扩展名,才可能是前端路由而不是静态资源。""" + last_segment = url_path.rstrip("/").rsplit("/", 1)[-1] + return "." not in last_segment + + +def _resolve_range(range_header, if_range, response_headers, size): + """解析 Range 头。 + + 返回 ``(start, end)``(闭区间,单段)表示应返回 206; + 返回 ``None`` 表示"忽略 Range、返回整份内容"; + 抛出 ``_RangeNotSatisfiable`` 表示应返回 416。 + + 按 RFC 9110:无法理解的单位、语法错误、多段请求都可以忽略 Range 直接返回整份内容; + 只有"语法正确但越界"才返回 416。多段 Range 未实现,按允许的方式退回整份内容。 + """ + if not range_header: + return None + + # If-Range 用来防止"取了半截文件之后文件被替换":不匹配就按整份返回 + if if_range is not None and if_range not in ( + response_headers["etag"], + response_headers["last-modified"], + ): + return None + + unit, _, value = range_header.partition("=") + if unit.strip().lower() != "bytes" or not value or "," in value: + return None + + start_text, _, end_text = value.strip().partition("-") + try: + if not start_text: + # bytes=-N:最后 N 个字节 + suffix_length = int(end_text) + if suffix_length <= 0: + raise ValueError + start, end = max(0, size - suffix_length), size - 1 + else: + start = int(start_text) + end = int(end_text) if end_text else size - 1 + except ValueError: + return None + + if start >= size or start > end: + raise _RangeNotSatisfiable() + return start, min(end, size - 1) + + +class _VFSFileResponse(Response): + """正文推迟到发送阶段、并在线程池里读取的响应。 + + 上游 FileResponse 也是这个模式(头部先按 stat 算好,正文流式读取)。 + 这么做的原因是:读 BLOB 与哈夫曼解压都是同步的 CPU/IO 工作, + 如果在构造响应时就做,就会阻塞事件循环。 + """ + + def __init__(self, statics, vfs_path, offset, length, status_code, headers): + super().__init__(content=b"", status_code=status_code, headers=headers) + self._statics = statics + self._vfs_path = vfs_path + self._offset = offset + self._length = length + + async def __call__(self, scope, receive, send): + if scope["method"] != "HEAD": + self.body = await run_in_threadpool( + self._statics._read, self._vfs_path, self._offset, self._length + ) + # HEAD 不读内容:content-length 已经在头部里给足了 + await super().__call__(scope, receive, send) + + +class SVFSStaticFiles(StaticFiles): + """以 SQLite 虚拟文件系统为后端的静态文件服务。 + + 参数: + vfs_db_path: .svfs 数据库路径,必须已存在(用 vfspack 生成) + html: 目录请求返回 index.html,并启用 404.html;默认 True + spa: 找不到且看起来是前端路由时回退到 index.html;默认 True + max_age: 设置 ``Cache-Control: public, max-age=<秒>``;默认 None(不设置) + readonly: 以只读方式打开数据库,默认 True。只读连接在打开阶段就是只读的, + 因此资源包所在目录不可写(只读挂载、容器镜像层)也能用, + 并且永远不会意外改动资源包 + """ + + def __init__( + self, + vfs_db_path, + *, + html=True, + spa=True, + max_age=None, + readonly=True, + ): + db_path = os.path.abspath(vfs_db_path) + if not os.path.exists(db_path): + raise RuntimeError( + f"虚拟文件系统不存在: {db_path};" + f"可以先用 `vfspack <源目录> {os.path.basename(db_path)}` 生成它" + ) + + self.vfs_db_path = db_path + self.spa = spa + self.max_age = max_age + # SQLite 连接不是线程安全的:读取发生在线程池里,这里自己负责串行化 + self._lock = threading.RLock() + try: + self._vfs = SQLiteVFS(db_path, readonly=readonly, check_same_thread=False) + except VFSFormatError: + raise + except Exception as exc: + raise RuntimeError(f"无法打开虚拟文件系统 {db_path}: {exc}") from exc + + # 静态内容不在磁盘上,所以不传 directory。 + # StaticFiles 明确支持 directory=None(此时 check_config 直接放行)。 + super().__init__(directory=None, html=html, check_dir=False) + + # ------------------------------------------------------------------ + # 生命周期 + # ------------------------------------------------------------------ + + def close(self): + """关闭底层数据库连接;请在应用 shutdown 时调用。""" + self._vfs.close() + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self.close() + + def __repr__(self): + return f"" + + # ------------------------------------------------------------------ + # StaticFiles 的钩子 + # ------------------------------------------------------------------ + + def lookup_path(self, path): + """把 URL 路径映射到库内路径,并合成 stat。 + + 返回 ``(库内路径, os.stat_result)`` 或 ``("", None)``。 + 上游 get_response 依赖 stat.S_ISREG / stat.S_ISDIR 判断文件还是目录。 + """ + vfs_path = _to_vfs_path(path) + if not vfs_path: + # 虚拟文件系统的根目录永远存在,这样 html=True 才能命中 /index.html + return "/", _to_stat_result(None) + with self._lock: + info = self._vfs.get_file_info(vfs_path) + if info is None: + return "", None + return info["path"], _to_stat_result(info) + + def file_response(self, full_path, stat_result, scope, status_code=200): + """静态内容不在磁盘上,无法复用 FileResponse,改从库里读字节。""" + return self._build_response(str(full_path), stat_result, scope, status_code) + + async def get_response(self, path, scope): + """在 StaticFiles 的路由决策之上加一层 SPA 回退。""" + try: + return await super().get_response(path, scope) + except HTTPException as exc: + if ( + exc.status_code != 404 + or not self.spa + or not _looks_like_navigation(path) + or not _wants_html(scope) + ): + raise + # 前端路由(/settings、/user/1 之类)交给 index.html + return await run_in_threadpool(self._index_fallback, scope) + + def _index_fallback(self, scope): + vfs_path, stat_result = self.lookup_path(f"/{INDEX_NAME}") + if stat_result is None: + raise HTTPException(status_code=404) + return self._build_response(vfs_path, stat_result, scope) + + # ------------------------------------------------------------------ + # 响应构造 + # ------------------------------------------------------------------ + + def _read(self, vfs_path, offset=0, size=None): + """读取(并解压)文件内容;调用方负责它跑在线程池里。""" + with self._lock: + return self._vfs.read_file(vfs_path, offset, size) + + def _build_response(self, vfs_path, stat_result, scope, status_code=200): + method = scope["method"] + request_headers = Headers(scope=scope) + size = stat_result.st_size + + headers = { + "content-type": _mime_type(vfs_path), + "last-modified": format_datetime( + datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc), + usegmt=True, + ), + "etag": _etag_for(stat_result), + "accept-ranges": "bytes", + } + if self.max_age is not None: + headers["cache-control"] = f"public, max-age={int(self.max_age)}" + + # 条件请求命中直接 304:BLOB 都不用读,更不用解压 + if self.is_not_modified(Headers(headers), request_headers): + return Response(status_code=304, headers=headers) + + try: + resolved = _resolve_range( + request_headers.get("range"), + request_headers.get("if-range"), + headers, + size, + ) + except _RangeNotSatisfiable: + return Response( + status_code=416, + headers={**headers, "content-range": f"bytes */{size}"}, + ) + + if resolved is not None: + start, end = resolved + length = end - start + 1 + return _VFSFileResponse( + self, + vfs_path, + offset=start, + length=length, + status_code=206, + headers={ + **headers, + "content-range": f"bytes {start}-{end}/{size}", + # HEAD 也要给出真实长度,所以显式设置 + "content-length": str(length), + }, + ) + + return _VFSFileResponse( + self, + vfs_path, + offset=0, + length=None, + status_code=status_code, + headers={**headers, "content-length": str(size)}, + ) diff --git a/src/sqlite_vfs/core.py b/src/sqlite_vfs/core.py index 6c3508d..b403dc1 100644 --- a/src/sqlite_vfs/core.py +++ b/src/sqlite_vfs/core.py @@ -1,442 +1,1595 @@ #!/usr/bin/env python3 -""" -SQLite虚拟文件系统实现 +"""SQLite 虚拟文件系统实现。 + +存储布局(schema v3) +--------------------- +``blobs`` + 内容寻址的存储:``hash`` 是 sha256(原始字节) 的十六进制,作为主键, + 所以**相同内容只存一份**。压缩后的数据、哈夫曼频率表、算法名都记在这里。 +``snapshots`` + 快照。一个库里可以有多个版本,每个快照有自己的条目集合、统计与摘要。 +``files`` + 目录项(含目录)。``UNIQUE(snapshot_id, path)``——同一个路径在不同快照里 + 各占一行,所以一个库能装下同一棵树的历史版本。文件内容不在这里, + 而是通过 ``content_hash`` 指向 ``blobs``。 +``signatures`` + 快照摘要的 HMAC-SHA256 签名,用于防篡改校验。 +``filesystem_metadata`` + 整包信息(名称、格式版本)。 + +为什么 file_size / modified_time 留在 ``files`` 而不跟着内容进 ``blobs``: +它们是"这个路径在快照里的元数据",且 FastAPI 插件的 ETag 与 Last-Modified +直接依赖它们。 """ +import hashlib +import hmac import sqlite3 import os -import zlib +import functools from datetime import datetime +from . import compression + +#: 当前 on-disk 格式版本。1 = 最早带 compressed 布尔列的版本, +#: 2 = 内容与算法列在 files 表里,3 = blobs + snapshots(本版本)。 +SCHEMA_VERSION = 3 + + +class VFSFormatError(Exception): + """数据库不是当前版本能够读取的格式(例如由旧版本创建)。""" + + +class VFSIntegrityError(Exception): + """数据完整性校验失败:内容缺失、大小对不上、哈希不匹配。""" + + +def _readonly_uri(db_path): + """构造 SQLite 只读 URI。 + + `mode=ro` 让 SQLite 在**打开阶段**就以只读方式打开文件, + 因此文件所在目录不可写时(只读挂载、容器镜像层)也能使用。 + 用 URI 时路径分隔符必须统一成正斜杠。 + """ + path = os.path.abspath(db_path).replace("\\", "/") + # '?' 和 '#' 在 URI 里有特殊含义,需要转义 + path = path.replace("?", "%3f").replace("#", "%23") + return f"file:{path}?mode=ro" + + +def _db_time(value): + """把 datetime 转成库里存储用的字符串。 + + **必须显式转换**,不能依赖 sqlite3 的默认 datetime adapter:后者自 Python 3.12 + 起已弃用(实测一次测试跑会产生 1240 条 DeprecationWarning),将来会被移除。 + 历史上 adapter 的实现就是 ``val.isoformat(" ")``,所以这里产出完全相同的文本, + 盘上格式与旧库都不受影响(有专门用例钉住这一点)。 + """ + if value is None: + return None + return value.isoformat(sep=" ") + + +def _key_bytes(key): + """把密钥统一成 bytes,便于直接喂给 hmac。""" + if isinstance(key, str): + return key.encode("utf-8") + return bytes(key) + + +def _writable(method): + """装饰器:在只读连接上调用写入方法时立刻报错。 + + 否则用户看到的会是 SQLite 底层的 "attempt to write a readonly database", + 或者更糟——被 add_file 的 except 吞成一句"添加文件失败"。 + """ + + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + self._require_writable() + return method(self, *args, **kwargs) + + return wrapper class SQLiteVFS: - def __init__(self, db_path, compress=False): + #: files 表必须具备的列 + REQUIRED_COLUMNS = frozenset( + { + "path", + "snapshot_id", + "content_hash", + "name", + "parent_path", + "is_directory", + "file_size", + "created_time", + "modified_time", + "permissions", + } + ) + + #: blobs 表必须具备的列 + REQUIRED_BLOB_COLUMNS = frozenset( + {"hash", "size", "stored_size", "content", "freq_blob", "compress_algo"} + ) + + #: snapshots 表必须具备的列 + REQUIRED_SNAPSHOT_COLUMNS = frozenset( + {"id", "name", "created_time", "parent_id", "total_files", "total_size", "digest"} + ) + + #: 参与快照摘要的字段(顺序固定,改这个就要同时接受摘要变化) + DIGEST_FIELDS = ( + "path", + "is_directory", + "file_size", + "modified_time", + "permissions", + "content_hash", + ) + + #: 各算法启用压缩的最小体积。 + #: zlib 的包头只有几字节,很小的文件也值得压;huffman 每个文件要带 1024 字节 + #: 频率表,小于 1KB 的文件压了反而更大。真正的"是否更小"判断在下方的 stored_size 里。 + COMPRESS_MIN_SIZE = {"zlib": 64, "huffman": 1025} + + def __init__( + self, + db_path, + compress=False, + compress_algorithm=compression.DEFAULT_ALGORITHM, + readonly=False, + check_same_thread=True, + snapshot_id=None, + ): + """打开一个虚拟文件系统。 + + 参数: + db_path: 数据库文件路径 + compress: 写入时是否压缩文件内容 + compress_algorithm: 压缩算法,'zlib'(默认)或 'huffman'。 + zlib 是标准库的 C 实现,压缩比更好、快几个数量级,新包建议用它; + 'huffman' 只为读得懂早期版本打的包而保留。 + readonly: 以只读方式打开(连接阶段即只读,不会建表建索引, + 也不会因为库文件不可写而失败)。适合"只读消费"的场景, + 例如把 .svfs 当作静态资源包来提供下载。 + check_same_thread: 传给 sqlite3.connect;置为 False 表示调用方 + 自己负责串行化多线程访问(SQLite 连接本身不是线程安全的) + snapshot_id: 操作哪个快照。None 表示"最新快照",任何写入都会在 + 库里还没有快照时自动建一个默认快照。 + """ + if compress_algorithm not in compression.ALGORITHMS: + raise ValueError( + f"未知压缩算法: {compress_algorithm!r};可用: " + f"{', '.join(compression.ALGORITHMS)}" + ) self.db_path = db_path self.compress = compress + self.compress_algorithm = compress_algorithm + self.readonly = readonly + self.check_same_thread = check_same_thread self.conn = None + self.snapshot_id = snapshot_id self.total_files = 0 self.total_size = 0 self.open_files = {} self.connect() + if snapshot_id is not None: + # 指定了快照就立刻校验它存在,否则后面所有查询都会静默返回空结果 + try: + self.use_snapshot(snapshot_id) + except Exception: + # 构造失败也必须释放连接,否则 Windows 上库文件会被锁住删不掉 + # (与 connect() 里同样的处理) + self.close() + raise def connect(self): - """连接到文件系统数据库""" - self.conn = sqlite3.connect(self.db_path) + """连接到文件系统数据库。 + + 如果目标库已存在但不是当前版本的格式,会抛出 VFSFormatError 并给出 + 迁移命令,而不是等到读写阶段才给出一连串难以理解的报错。 + """ + self.close() + if self.readonly: + self.conn = sqlite3.connect( + _readonly_uri(self.db_path), + uri=True, + check_same_thread=self.check_same_thread, + ) + else: + self.conn = sqlite3.connect( + self.db_path, check_same_thread=self.check_same_thread + ) self.conn.row_factory = sqlite3.Row - self._create_tables() + try: + self._check_schema() + except Exception: + # 失败时立刻释放连接,否则 Windows 上文件会被锁住删不掉 + self.close() + raise - def clean(self): - if self.conn is None: - raise ValueError("数据库连接未建立") - cursor = self.conn.cursor() - cursor.execute("DELETE FROM files") - cursor.execute("DELETE FROM filesystem_metadata") + def _require_writable(self): + """只读模式下修改数据时给出明确报错。""" + if self.readonly: + raise PermissionError( + f"{self.db_path} 是以只读方式打开的,无法修改;" + f"需要写入时请用 SQLiteVFS(path, readonly=False)。" + ) - def add_vfs_metadata(self, name): - """添加文件系统元数据""" - if self.conn is None: - raise ValueError("数据库连接未建立") - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO filesystem_metadata (name) - VALUES (?) - """, - (name,), + # ------------------------------------------------------------------ + # schema + # ------------------------------------------------------------------ + + def _table_exists(self, name): + return ( + self.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ).fetchone() + is not None ) - self.conn.commit() - def update_vfs_metadata(self, name, total_files, total_size): - """更新文件系统元数据""" + def _table_columns(self, name): + return {row["name"] for row in self.conn.execute(f"PRAGMA table_info({name})")} + + def _check_schema(self): + """校验既有库的 schema;库或 files 表还不存在时才建表。""" if self.conn is None: raise ValueError("数据库连接未建立") - cursor = self.conn.cursor() - cursor.execute( - """ - UPDATE filesystem_metadata - SET total_files = ?, total_size = ? - WHERE name = ? - """, - (total_files, total_size, name), + + if not self._table_exists("files"): + if self.readonly: + raise VFSFormatError( + f"{self.db_path} 不是有效的虚拟文件系统数据库(没有 files 表)" + ) + self._create_tables() + return + + if not self._table_exists("blobs") or not self._table_exists("snapshots"): + self._raise_legacy_format() + + missing = sorted( + self.REQUIRED_COLUMNS - self._table_columns("files") + ) + if not missing: + missing = sorted( + self.REQUIRED_BLOB_COLUMNS - self._table_columns("blobs") + ) + if not missing: + missing = sorted( + self.REQUIRED_SNAPSHOT_COLUMNS - self._table_columns("snapshots") + ) + if missing: + self._raise_legacy_format(missing) + + if not self.readonly: + # 只读连接不能执行 DDL;索引由打包端负责创建 + self._create_indexes() + + def _raise_legacy_format(self, missing=None): + """旧格式库给出带迁移命令的报错。""" + detail = f"(缺少: {', '.join(missing)})" if missing else "" + raise VFSFormatError( + f"{self.db_path} 是旧格式的虚拟文件系统{detail},当前版本(schema " + f"{SCHEMA_VERSION})需要 blobs + snapshots 结构。" + f"请先迁移并保留备份:vfsmigrate \"{self.db_path}\"" ) - self.conn.commit() def _create_tables(self): - """创建必要的表""" + """创建全部表""" if self.conn is None: raise ValueError("数据库连接未建立") cursor = self.conn.cursor() - # 创建文件系统元数据表 cursor.execute( """ CREATE TABLE IF NOT EXISTS filesystem_metadata ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, - total_files INTEGER DEFAULT 0, - total_size INTEGER DEFAULT 0 + schema_version INTEGER NOT NULL DEFAULT 3 + ) + """ + ) + + # 内容寻址存储:hash 主键 = "相同内容只存一份" + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS blobs ( + hash TEXT PRIMARY KEY, + size INTEGER NOT NULL, + stored_size INTEGER NOT NULL, + content BLOB NOT NULL, + freq_blob BLOB, + compress_algo TEXT NOT NULL DEFAULT 'none' + ) + """ + ) + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_time DATETIME, + parent_id INTEGER, + total_files INTEGER DEFAULT 0, + total_size INTEGER DEFAULT 0, + digest TEXT ) """ ) - # 创建文件表 cursor.execute( """ CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE, + snapshot_id INTEGER NOT NULL, + path TEXT NOT NULL, name TEXT NOT NULL, parent_path TEXT, is_directory BOOLEAN NOT NULL DEFAULT 0, file_size INTEGER DEFAULT 0, created_time DATETIME, modified_time DATETIME, - permissions INTEGER DEFAULT 0644, - content BLOB, - compressed BOOLEAN DEFAULT 0 + permissions INTEGER DEFAULT 420, -- 0o644 + content_hash TEXT, -- 目录为 NULL + UNIQUE (snapshot_id, path) ) """ ) - # 创建索引 - cursor.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)") cursor.execute( - "CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_path)" - ) - cursor.execute( - "CREATE INDEX IF NOT EXISTS idx_files_directory ON files(is_directory)" + """ + CREATE TABLE IF NOT EXISTS signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + algorithm TEXT NOT NULL, + key_id TEXT, + signature TEXT NOT NULL, + signed_time DATETIME + ) + """ ) + self._create_indexes() + + def _create_indexes(self): + """创建索引(幂等)。 + + UNIQUE(snapshot_id, path) 自带索引,(snapshot_id, parent_path) 服务于 + 目录列举,content_hash 服务于引用计数与去重统计。 + """ + if self.conn is None: + raise ValueError("数据库连接未建立") + cursor = self.conn.cursor() + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_files_snapshot_parent " + "ON files(snapshot_id, parent_path)" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash)" + ) self.conn.commit() - def add_directory(self, rel_path, source_folder): - """添加目录到数据库""" + # ------------------------------------------------------------------ + # 路径与查询辅助 + # ------------------------------------------------------------------ + + @staticmethod + def _normalize(path): + """统一成库内使用的形式:正斜杠分隔、不带首尾斜杠。""" + if path is None: + return "" + return path.replace("\\", "/").strip("/") + + @staticmethod + def _escape_like(value): + """转义 LIKE 模式中的通配符。 + + 路径里的 % 和 _ 是常见的合法文件名字符,不转义会让 + `remove("doc")` 之类的操作误伤 `docs`。 + """ + escaped = value.replace("\\", "\\\\") + escaped = escaped.replace("%", "\\%").replace("_", "\\_") + return escaped + + def _ensure_snapshot(self): + """确保有一个可写的当前快照;库为空时懒建默认快照。 + + 未指定快照时默认落在**最新**快照上("当前版本"的直觉语义)。 + """ + if self.snapshot_id is not None: + return self.snapshot_id + row = self.conn.execute( + "SELECT id FROM snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is not None: + self.snapshot_id = row["id"] + return self.snapshot_id + self.snapshot_id = self._insert_snapshot_row( + self.conn.cursor(), name="default", parent_id=None + ) + self.conn.commit() + return self.snapshot_id + + def _active_snapshot(self): + """当前快照 id;库里没有任何快照时返回 None(读路径会返回空结果)。 + + 注意与 _ensure_snapshot 的区别:只读连接上绝不能走这条创建路径, + 否则"只读消费"就变成写操作了。 + """ + if self.snapshot_id is not None: + return self.snapshot_id + row = self.conn.execute( + "SELECT id FROM snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + return None + self.snapshot_id = row["id"] + return self.snapshot_id + + # ------------------------------------------------------------------ + # 快照 + # ------------------------------------------------------------------ + + def _insert_snapshot_row(self, cursor, name, parent_id=None): + now = datetime.now() + cursor.execute( + """ + INSERT INTO snapshots (name, created_time, parent_id, total_files, total_size) + VALUES (?, ?, ?, 0, 0) + """, + (name, _db_time(now), parent_id), + ) + return cursor.lastrowid + + @_writable + def create_snapshot(self, name=None, parent_id=None, copy_entries=False): + """新建一个快照,返回它的 id。 + + 参数: + name: 快照名,默认 "snapshot-" 形式由调用方决定,这里用 "snapshot" + parent_id: 父快照 id,只作记录("这一版从哪来") + copy_entries: 为 True 时把 parent_id 的目录项整份复制过来(只复制元数据行, + 内容 blob 是共享的,所以新建一版几乎不占空间)。省略 parent_id 时 + 取当前快照。 + + 新快照会立即成为当前快照。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + if parent_id is None and copy_entries: + parent_id = self._active_snapshot() + + cursor = self.conn.cursor() try: - abs_path = os.path.join(source_folder, rel_path) - stat = os.stat(abs_path) - - if self.conn is None: - raise ValueError("数据库连接未建立") - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO files - (path, name, parent_path, is_directory, created_time, modified_time, permissions) - VALUES (?, ?, ?, 1, ?, ?, ?) - """, - ( - rel_path.replace("\\", "/"), - os.path.basename(rel_path), - # 将 \ 替换为 / - os.path.dirname(rel_path).replace("\\", "/"), - datetime.fromtimestamp(stat.st_ctime), - datetime.fromtimestamp(stat.st_mtime), - stat.st_mode & 0o777, - ), + new_id = self._insert_snapshot_row( + cursor, name or f"snapshot-{parent_id or 0}", parent_id ) - - except Exception as e: - print(f"添加目录失败 {rel_path}: {e}") - - else: + if copy_entries: + if parent_id is None: + raise ValueError("没有可复制的父快照") + cursor.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, file_size, + created_time, modified_time, permissions, content_hash) + SELECT ?, path, name, parent_path, is_directory, file_size, + created_time, modified_time, permissions, content_hash + FROM files WHERE snapshot_id = ? + """, + (new_id, parent_id), + ) self.conn.commit() + except Exception: + self.conn.rollback() + raise - def add_file(self, abs_path, rel_path): - """添加文件到数据库""" - try: - stat = os.stat(abs_path) - file_size = stat.st_size + self.snapshot_id = new_id + self.recount() + return new_id - # 读取文件内容 - with open(abs_path, "rb") as f: - content = f.read() - - # 压缩处理 - compressed = False - if self.compress and len(content) > 1024: # 大于1KB的文件才压缩 - compressed_content = zlib.compress(content, level=6) - if len(compressed_content) < len(content): - content = compressed_content - compressed = True - - if self.conn is None: - raise ValueError("数据库连接未建立") - cursor = self.conn.cursor() - cursor.execute( - """ - INSERT INTO files - (path, name, parent_path, is_directory, file_size, - created_time, modified_time, permissions, content, compressed) - VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?) - """, - ( - rel_path.replace("\\", "/"), - os.path.basename(rel_path), - os.path.dirname(rel_path).replace("\\", "/"), - file_size, - datetime.fromtimestamp(stat.st_ctime), - datetime.fromtimestamp(stat.st_mtime), - stat.st_mode & 0o777, - sqlite3.Binary(content), - compressed, - ), - ) - - self.total_files += 1 - self.total_size += file_size - - print(f"添加文件: {rel_path} ({file_size} bytes)") - - except Exception as e: - print(f"添加文件失败 {rel_path}: {e}") - - def get_file_info(self, path): - """获取文件信息""" + def use_snapshot(self, snapshot_id): + """切换当前快照。""" if self.conn is None: raise ConnectionError("文件系统尚未连接") - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM files WHERE path = ?", (path.lstrip("/"),)) - row = cursor.fetchone() - if row: - # 将时间字符串转换为datetime对象 - return self._parse_row_datetime(row) - return None + row = self.conn.execute( + "SELECT id FROM snapshots WHERE id = ?", (snapshot_id,) + ).fetchone() + if row is None: + raise FileNotFoundError(f"快照不存在: {snapshot_id}") + self.snapshot_id = row["id"] + return self.snapshot_id - def list_directory(self, path): - """列出目录内容""" + def list_snapshots(self): + """列出所有快照(含各自统计与摘要)。""" if self.conn is None: raise ConnectionError("文件系统尚未连接") - if path == "/": - sql = """ - SELECT * FROM files - ORDER BY is_directory DESC, name ASC - """ - else: - sql = f""" - SELECT * FROM files - WHERE path = '{path.lstrip("/")}' - ORDER BY is_directory DESC, name ASC - """ - cursor = self.conn.cursor() - cursor.execute(sql) - rows = cursor.fetchall() - # 将时间字符串转换为datetime对象 + rows = self.conn.execute( + "SELECT * FROM snapshots ORDER BY id" + ).fetchall() return [self._parse_row_datetime(row) for row in rows] - def _parse_row_datetime(self, row): - """将行中的时间字符串转换为datetime对象""" - from datetime import datetime - - parsed_row = dict(row) - - # 处理created_time - if parsed_row["created_time"] and isinstance(parsed_row["created_time"], str): - try: - parsed_row["created_time"] = datetime.fromisoformat( - parsed_row["created_time"].replace("Z", "+00:00") - ) - except ValueError: - # 如果格式不匹配,尝试其他格式 - try: - parsed_row["created_time"] = datetime.strptime( - parsed_row["created_time"], "%Y-%m-%d %H:%M:%S" - ) - except ValueError: - # 如果无法解析,保持原样 - pass - - # 处理modified_time - if parsed_row["modified_time"] and isinstance(parsed_row["modified_time"], str): - try: - parsed_row["modified_time"] = datetime.fromisoformat( - parsed_row["modified_time"].replace("Z", "+00:00") - ) - except ValueError: - try: - parsed_row["modified_time"] = datetime.strptime( - parsed_row["modified_time"], "%Y-%m-%d %H:%M:%S" - ) - except ValueError: - pass - - return parsed_row - - def read_file(self, path, offset=0, size=None): - """读取文件内容""" - file_info = self.get_file_info(path) - if not file_info: - raise FileNotFoundError(f"文件不存在: {path}") - - if file_info["is_directory"]: - raise IsADirectoryError(f"这是一个目录: {path}") - - content = file_info["content"] - - # 解压缩 - if file_info["compressed"]: - content = zlib.decompress(content) - - if size is None: - size = len(content) - offset - - return content[offset : offset + size] - - def mv_file(self, src, dest): - """移动文件""" + @_writable + def delete_snapshot(self, snapshot_id, gc=True): + """删除一个快照(连同它的目录项与签名),返回被删掉的条目数。""" if self.conn is None: raise ConnectionError("文件系统尚未连接") cursor = self.conn.cursor() + try: + cursor.execute("DELETE FROM signatures WHERE snapshot_id = ?", (snapshot_id,)) + deleted = cursor.execute( + "DELETE FROM files WHERE snapshot_id = ?", (snapshot_id,) + ).rowcount + cursor.execute("DELETE FROM snapshots WHERE id = ?", (snapshot_id,)) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + if self.snapshot_id == snapshot_id: + self.snapshot_id = None + if gc: + self.gc_blobs() + return deleted + + @_writable + def clear_snapshot(self, snapshot_id=None): + """只清空某个快照的目录项(其它快照不受影响)。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._ensure_snapshot() + cursor = self.conn.cursor() + try: + cursor.execute("DELETE FROM files WHERE snapshot_id = ?", (sid,)) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + self.snapshot_id = sid + self.recount() + self.gc_blobs() + + # ------------------------------------------------------------------ + # 库级元数据 + # ------------------------------------------------------------------ + + @_writable + def clean(self): + """清空整库:所有快照、条目、blob、签名与元数据全部删除。""" + if self.conn is None: + raise ValueError("数据库连接未建立") + cursor = self.conn.cursor() + cursor.execute("DELETE FROM files") + cursor.execute("DELETE FROM blobs") + cursor.execute("DELETE FROM snapshots") + cursor.execute("DELETE FROM signatures") + cursor.execute("DELETE FROM filesystem_metadata") + self.conn.commit() + self.snapshot_id = None + self.total_files = 0 + self.total_size = 0 + + @_writable + def add_vfs_metadata(self, name): + """写入整包元数据;库里还没有快照时,顺便建一个同名默认快照。""" + if self.conn is None: + raise ValueError("数据库连接未建立") + cursor = self.conn.cursor() cursor.execute( """ - UPDATE files - SET path = ?, name = ?, parent_path = ? - WHERE path = ? + INSERT INTO filesystem_metadata (name, schema_version) + VALUES (?, ?) """, - ( - dest.lstrip("/"), - os.path.basename(dest), - os.path.dirname(dest).lstrip("/"), - src.lstrip("/"), - ), + (name, SCHEMA_VERSION), ) self.conn.commit() + if self.snapshot_id is None and not self.conn.execute( + "SELECT 1 FROM snapshots LIMIT 1" + ).fetchone(): + self.snapshot_id = self._insert_snapshot_row(cursor, name=name) + self.conn.commit() - def mk_dir(self, path): - """创建目录""" + @_writable + def update_vfs_metadata(self, name, total_files, total_size): + """更新整包名称与当前快照的统计信息。""" if self.conn is None: - raise ConnectionError("文件系统尚未连接") + raise ValueError("数据库连接未建立") cursor = self.conn.cursor() cursor.execute( - """ - INSERT INTO files - (path, name, parent_path, is_directory, created_time, modified_time, permissions) - VALUES (?, ?, ?, 1, ?, ?, ?) - """, - ( - path.lstrip("/"), - os.path.basename(path), - os.path.dirname(path).lstrip("/"), - datetime.now(), - datetime.now(), - 0o755, - ), + "UPDATE filesystem_metadata SET name = ?", (name,) + ) + sid = self._ensure_snapshot() + cursor.execute( + "UPDATE snapshots SET total_files = ?, total_size = ? WHERE id = ?", + (total_files, total_size, sid), ) - self.conn.commit() - - def mv_dir(self, src, dest): - """移动目录""" - if self.conn is None: - raise ConnectionError("文件系统尚未连接") - - cursor = self.conn.cursor() - - if src == "/": - # 移动根目录下所有内容到指定目录 - # 先处理目录,避免路径冲突 - cursor.execute( - """ - UPDATE files - SET path = ? || '/' || path, - parent_path = ? || '/' || parent_path - WHERE path != '' AND is_directory = 1 - """, - (dest.lstrip("/"), dest.lstrip("/")), - ) - - # 再处理文件 - cursor.execute( - """ - UPDATE files - SET path = ? || '/' || path, - parent_path = CASE - WHEN parent_path = '' THEN ? - ELSE ? || '/' || parent_path - END - WHERE is_directory = 0 - """, - (dest.lstrip("/"), dest.lstrip("/"), dest.lstrip("/")), - ) - else: - # 移动指定目录到目标位置 - # 更新目录本身 - cursor.execute( - """ - UPDATE files - SET path = ?, - name = ?, - parent_path = ? - WHERE path = ? - """, - ( - dest.lstrip("/"), - os.path.basename(dest), - os.path.dirname(dest).lstrip("/"), - src.lstrip("/"), - ), - ) - - # 更新目录下的所有内容 - cursor.execute( - """ - UPDATE files - SET path = ? || SUBSTR(path, ?), - parent_path = ? || SUBSTR(parent_path, ?) - WHERE path LIKE ? || '%' - """, - ( - dest.lstrip("/"), - len(src.lstrip("/")) + 1, - dest.lstrip("/"), - len(src.lstrip("/")) + 1, - src.lstrip("/"), - ), - ) - self.conn.commit() def get_stats(self): - """获取文件系统统计信息""" - if self.conn is None: - raise ConnectionError("文件系统尚未连接") - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM filesystem_metadata WHERE id = 1") - return cursor.fetchone() + """获取文件系统统计信息。 - def remove(self, path): - """删除文件或目录""" + 返回 dict:保留 name / created_time / total_files / total_size 四个历史键, + 另外给出 snapshot_id / snapshot_name / digest / schema_version。 + """ if self.conn is None: raise ConnectionError("文件系统尚未连接") - if self.is_dir(path): # 检查是否为目录 - self._remove_directory(path) - else: # 检查是否为文件 - self._remove_file(path) + stats = {} + row = self.conn.execute( + "SELECT * FROM filesystem_metadata ORDER BY id LIMIT 1" + ).fetchone() + if row is not None: + stats.update(self._parse_row_datetime(row)) + sid = self._active_snapshot() + stats["snapshot_id"] = sid + if sid is not None: + snapshot = self.conn.execute( + "SELECT * FROM snapshots WHERE id = ?", (sid,) + ).fetchone() + if snapshot is not None: + snapshot = self._parse_row_datetime(snapshot) + stats["snapshot_name"] = snapshot["name"] + stats["digest"] = snapshot["digest"] + # 统计信息现在挂在快照上,这里透出以保持历史键的含义 + stats["total_files"] = snapshot["total_files"] or 0 + stats["total_size"] = snapshot["total_size"] or 0 + stats.setdefault("name", None) + stats.setdefault("total_files", 0) + stats.setdefault("total_size", 0) + return stats + + # ------------------------------------------------------------------ + # 写入 + # ------------------------------------------------------------------ + + @_writable + def add_directory(self, rel_path, source_folder, replace=False): + """添加目录到数据库。 + + 返回是否成功。失败时不会中断调用方(例如打包时个别目录无权限读取), + 但会回滚事务并返回 False,由调用方决定如何处理。 + + replace=True 时先删掉同路径的旧条目再写入(增量打包时覆盖用)。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + try: + abs_path = os.path.join(source_folder, rel_path) + stat = os.stat(abs_path) + snapshot_id = self._ensure_snapshot() + normalized = self._normalize(rel_path) + + cursor = self.conn.cursor() + if replace: + cursor.execute( + "DELETE FROM files WHERE snapshot_id = ? AND path = ?", + (snapshot_id, normalized), + ) + cursor.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, + created_time, modified_time, permissions) + VALUES (?, ?, ?, ?, 1, ?, ?, ?) + """, + ( + snapshot_id, + normalized, + os.path.basename(rel_path), + self._normalize(os.path.dirname(rel_path)), + _db_time(datetime.fromtimestamp(stat.st_ctime)), + _db_time(datetime.fromtimestamp(stat.st_mtime)), + stat.st_mode & 0o777, + ), + ) + self.conn.commit() + return True + + except Exception as e: + self.conn.rollback() + print(f"添加目录失败 {rel_path}: {e}") + return False + + def _blob_exists(self, digest): + return ( + self.conn.execute( + "SELECT 1 FROM blobs WHERE hash = ?", (digest,) + ).fetchone() + is not None + ) + + def _store_blob(self, cursor, raw): + """把内容存进 blobs(已存在则跳过),返回 (hash, 是否命中已有 blob)。""" + digest = hashlib.sha256(raw).hexdigest() + if self._blob_exists(digest): + # 去重命中:内容一模一样,连压缩都不用做 + return digest, True + + algo, freq_blob, packed = compression.compress_with( + raw, self.compress_algorithm + ) if self.compress else ("none", None, raw) + + if algo != "none" and compression.stored_size(packed, freq_blob) >= len(raw): + # 压不动就原样存(huffman 的频率表要算进去) + algo, freq_blob, packed = "none", None, raw + + stored_size = compression.stored_size(packed, freq_blob) + cursor.execute( + """ + INSERT INTO blobs (hash, size, stored_size, content, freq_blob, compress_algo) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + digest, + len(raw), + stored_size, + sqlite3.Binary(packed), + sqlite3.Binary(freq_blob) if freq_blob else None, + algo, + ), + ) + return digest, False + + @_writable + def add_file(self, abs_path, rel_path, replace=False): + """添加文件到数据库。 + + 返回是否成功;成功时数据已经提交(不再依赖调用方后续的 commit)。 + 内容按 sha256 去重:库里已有同样内容时不会再存一份,也不会重复压缩。 + + replace=True 时先删掉同路径的旧条目再写入(增量打包时覆盖用)。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + try: + stat = os.stat(abs_path) + with open(abs_path, "rb") as f: + raw = f.read() + snapshot_id = self._ensure_snapshot() + normalized = self._normalize(rel_path) + + cursor = self.conn.cursor() + digest, dedup_hit = self._store_blob(cursor, raw) + if replace: + cursor.execute( + "DELETE FROM files WHERE snapshot_id = ? AND path = ?", + (snapshot_id, normalized), + ) + cursor.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, file_size, + created_time, modified_time, permissions, content_hash) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?) + """, + ( + snapshot_id, + normalized, + os.path.basename(rel_path), + self._normalize(os.path.dirname(rel_path)), + stat.st_size, + _db_time(datetime.fromtimestamp(stat.st_ctime)), + _db_time(datetime.fromtimestamp(stat.st_mtime)), + stat.st_mode & 0o777, + digest, + ), + ) + self.conn.commit() + self.total_files += 1 + self.total_size += stat.st_size + mark = " 去重" if dedup_hit else "" + print(f"添加文件: {rel_path} ({stat.st_size} bytes{mark})") + return True + + except Exception as e: + self.conn.rollback() + print(f"添加文件失败 {rel_path}: {e}") + return False + + def recount(self): + """按库内实际内容重算当前快照的 total_files / total_size 并落库。 + + 只统计文件、不含目录。顺带刷新快照摘要,避免统计与摘要各自为政。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = self._active_snapshot() + if sid is None: + self.total_files = 0 + self.total_size = 0 + return self.total_files, self.total_size + row = self.conn.execute( + """ + SELECT COUNT(*) AS files, COALESCE(SUM(file_size), 0) AS size + FROM files WHERE snapshot_id = ? AND is_directory = 0 + """, + (sid,), + ).fetchone() + self.total_files = row["files"] + self.total_size = row["size"] + if not self.readonly: + self.conn.execute( + "UPDATE snapshots SET total_files = ?, total_size = ? WHERE id = ?", + (self.total_files, self.total_size, sid), + ) + self.conn.commit() + self.compute_digest(sid) + return self.total_files, self.total_size + + # ------------------------------------------------------------------ + # 读取 + # ------------------------------------------------------------------ + + #: 目录项的显式列名。**刻意不用 SELECT ***:内容已经在 blobs 里, + #: 元数据查询不该把 BLOB 拖进内存(历史上 exists()/list_directory() 就在做这件事) + FILE_COLUMNS = ( + "id", + "snapshot_id", + "path", + "name", + "parent_path", + "is_directory", + "file_size", + "created_time", + "modified_time", + "permissions", + "content_hash", + ) + + def _select_file_sql(self, where): + return ( + f"SELECT {', '.join(self.FILE_COLUMNS)} FROM files WHERE {where}" + ) + + def get_file_info(self, path): + """获取目录项元数据(**不含内容**)。 + + 需要内容请用 read_file(),需要看它对应哪个 blob 请用 get_blob_info()。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = self._active_snapshot() + if sid is None: + return None + row = self.conn.execute( + self._select_file_sql("snapshot_id = ? AND path = ?"), + (sid, self._normalize(path)), + ).fetchone() + return self._parse_row_datetime(row) if row else None + + def get_blob_info(self, path): + """查看某个文件对应的 blob(哈希、原始大小、实际占用、压缩算法)。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = self._active_snapshot() + if sid is None: + return None + row = self.conn.execute( + """ + SELECT b.*, f.file_size AS file_size + FROM files f LEFT JOIN blobs b ON b.hash = f.content_hash + WHERE f.snapshot_id = ? AND f.path = ? + """, + (sid, self._normalize(path)), + ).fetchone() + if row is None: + return None + info = dict(row) + if info.get("hash") is None: + # 目录(或没有关联内容的条目)本来就没有 blob,返回 None 比一堆空字段清楚 + return None + return info + + # ------------------------------------------------------------------ + # 内容寻址原语:给"按 hash 消费内容"的场景用(发布到对象存储、去重统计等) + # ------------------------------------------------------------------ + + def all_entries(self, snapshot_id=None): + """列出某快照的全部条目(按 path 排序),含目录。 + + 与 list_directory 的区别:这个一次性给出整棵树,用于批量导出/比对。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + if sid is None: + return [] + rows = self.conn.execute( + self._select_file_sql("snapshot_id = ?") + " ORDER BY path", (sid,) + ).fetchall() + return [self._parse_row_datetime(row) for row in rows] + + def list_blobs(self): + """列出库里所有 blob 的元数据(不含内容),按 hash 排序。 + + 返回的每一行都有 hash / size / stored_size / compress_algo, + 用 read_blob(hash) 取原始内容。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + rows = self.conn.execute( + "SELECT hash, size, stored_size, compress_algo FROM blobs ORDER BY hash" + ).fetchall() + return [dict(row) for row in rows] + + def read_blob(self, content_hash, verify_hash=False): + """按内容哈希读取**原始(未压缩)**内容。 + + 这是内容寻址存储的读原语:调用方不需要知道库里用的是哪种压缩算法。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + row = self.conn.execute( + "SELECT hash, size, content, freq_blob, compress_algo FROM blobs WHERE hash = ?", + (content_hash,), + ).fetchone() + if row is None: + raise FileNotFoundError(f"blob 不存在: {content_hash}") + + content = compression.decompress_with( + row["compress_algo"], row["freq_blob"], row["content"] + ) + if len(content) != row["size"]: + raise VFSIntegrityError( + f"blob {content_hash} 的大小与记录不符:记录 {row['size']},实际 {len(content)}" + ) + if verify_hash: + actual = hashlib.sha256(content).hexdigest() + if actual != content_hash: + raise VFSIntegrityError( + f"blob 内容哈希不匹配:记录 {content_hash},实际 {actual}" + ) + return content + + def list_directory(self, path): + """列出目录下的直接子项(不递归)。 + + path 为 "/" 或空时列出根目录内容。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = self._active_snapshot() + if sid is None: + return [] + normalized = self._normalize(path) + + if not normalized: + # 根目录:顶层条目在库里的 parent_path 可能是 NULL / '' / '/' + sql = self._select_file_sql( + "snapshot_id = ? AND (parent_path IS NULL OR parent_path = '' " + "OR parent_path = '/')" + ) + params = (sid,) + else: + # 必须按 parent_path 过滤:按 path 过滤只会返回目录自己, + # 让 export_directory 陷入自我递归 + sql = self._select_file_sql("snapshot_id = ? AND parent_path = ?") + params = (sid, normalized) + + sql += " ORDER BY is_directory DESC, name ASC" + rows = self.conn.execute(sql, params).fetchall() + return [self._parse_row_datetime(row) for row in rows] + + @staticmethod + def parse_time(value): + """把库内的时间字段解析成 datetime;解析不了就返回 None。 + + 单独抽出来是因为增量打包也要用它比较 mtime,两处实现漂移会让 + "没变的文件被当成变了"这种问题极难排查。 + """ + if not value: + return None + if not isinstance(value, str): + return value + for parser in ( + lambda v: datetime.fromisoformat(v.replace("Z", "+00:00")), + lambda v: datetime.strptime(v, "%Y-%m-%d %H:%M:%S"), + ): + try: + return parser(value) + except ValueError: + continue + return None + + def _parse_row_datetime(self, row): + """将行中的时间字符串转换为datetime对象""" + parsed_row = dict(row) + + for field in ("created_time", "modified_time", "signed_time"): + if field in parsed_row: + parsed = self.parse_time(parsed_row[field]) + if parsed is not None: + parsed_row[field] = parsed + + return parsed_row + + def read_file(self, path, offset=0, size=None, verify_hash=False): + """读取文件内容(按库内记录的算法自动解压)。 + + verify_hash=True 时会顺带校验内容的 sha256 与库里记录的是否一致 + (代价是每次读取都要重算一遍哈希)。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = self._active_snapshot() + if sid is None: + raise FileNotFoundError(f"文件不存在: {path}") + normalized = self._normalize(path) + + row = self.conn.execute( + """ + SELECT f.is_directory, f.file_size, f.content_hash, + b.size AS blob_size, b.content, b.freq_blob, b.compress_algo + FROM files f LEFT JOIN blobs b ON b.hash = f.content_hash + WHERE f.snapshot_id = ? AND f.path = ? + """, + (sid, normalized), + ).fetchone() + + if row is None: + raise FileNotFoundError(f"文件不存在: {path}") + if row["is_directory"]: + raise IsADirectoryError(f"这是一个目录: {path}") + if row["content_hash"] is None: + raise VFSIntegrityError( + f"文件 {path} 没有关联内容(content_hash 为 NULL)" + ) + if row["content"] is None: + raise VFSIntegrityError( + f"文件 {path} 的内容在 blobs 里找不到(hash={row['content_hash']})," + f"数据库可能被裁剪或损坏" + ) + if row["blob_size"] != row["file_size"]: + # 便宜的结构性校验:大小对不上就不要继续解压了 + raise VFSIntegrityError( + f"文件 {path} 元数据与内容不一致:files.file_size=" + f"{row['file_size']},blobs.size={row['blob_size']}" + ) + + try: + content = compression.decompress_with( + row["compress_algo"], row["freq_blob"], row["content"] + ) + except ValueError as exc: + raise ValueError(f"文件 {path} 解压失败: {exc}") from exc + + if verify_hash: + actual = hashlib.sha256(content).hexdigest() + if actual != row["content_hash"]: + raise VFSIntegrityError( + f"文件 {path} 内容哈希不匹配:记录 {row['content_hash']}," + f"实际 {actual}" + ) + + if offset < 0: + raise ValueError(f"offset 不能为负: {offset}") + if size is not None and size < 0: + raise ValueError(f"size 不能为负: {size}") + + if size is None: + size = len(content) - offset + return content[offset : offset + size] + + # ------------------------------------------------------------------ + # 完整性:摘要 / 校验 / 签名 + # ------------------------------------------------------------------ + + @staticmethod + def _digest_mtime_text(value): + """把时间戳规范化成稳定文本,保证摘要不受存储格式与本地时区影响。""" + if not value: + return "" + if isinstance(value, str): + for parser in ( + lambda v: datetime.fromisoformat(v.replace("Z", "+00:00")), + lambda v: datetime.strptime(v, "%Y-%m-%d %H:%M:%S"), + ): + try: + value = parser(value) + break + except ValueError: + continue + else: + return value + try: + return value.isoformat() + except AttributeError: + return str(value) + + def _digest_rows(self, snapshot_id): + rows = self.conn.execute( + f"SELECT {', '.join(self.DIGEST_FIELDS)} FROM files " + f"WHERE snapshot_id = ? ORDER BY path", + (snapshot_id,), + ).fetchall() + lines = [] + for row in rows: + lines.append( + "\t".join( + [ + row["path"], + "d" if row["is_directory"] else "f", + str(row["file_size"] or 0), + self._digest_mtime_text(row["modified_time"]), + str(row["permissions"] or 0), + row["content_hash"] or "-", + ] + ) + ) + return "\n".join(lines) + + def compute_digest(self, snapshot_id=None, store=True): + """计算并(默认)保存快照摘要。 + + 摘要是对"按 path 排序后的所有条目"做 sha256,参与字段见 DIGEST_FIELDS, + 所以任何路径、大小、时间戳、权限或内容哈希的变化都会改变摘要。 + + **语义要点**:走本类 API 的写入会在 recount() 时刷新摘要,所以摘要本身 + 防的是"绕过 API 的改动"(有人拿 SQLite 工具直接改库)。要真正当防篡改凭证用, + 应当在打包完成后立刻 sign() 或把摘要记到库外;之后任何改动都会让签名/比对失败。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + if sid is None: + return None + digest = hashlib.sha256(self._digest_rows(sid).encode("utf-8")).hexdigest() + if store and not self.readonly: + self.conn.execute( + "UPDATE snapshots SET digest = ? WHERE id = ?", (digest, sid) + ) + self.conn.commit() + return digest + + def verify(self, snapshot_id=None, check_content=False): + """校验快照完整性,返回报告(不抛异常,除非库不可读)。 + + 检查项: + * 摘要是否与记录一致(能发现路径/大小/时间戳/权限/内容哈希被改动) + * 每个文件的 blob 是否存在、大小是否与 files.file_size 一致 + * check_content=True 时逐文件重算 sha256(O(总字节数)) + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + report = { + "ok": True, + "snapshot_id": sid, + "expected_digest": None, + "actual_digest": None, + "digest_ok": False, + "files_checked": 0, + "content_checked": 0, + "errors": [], + } + if sid is None: + report["errors"].append("库里没有任何快照") + report["ok"] = False + return report + + snapshot = self.conn.execute( + "SELECT * FROM snapshots WHERE id = ?", (sid,) + ).fetchone() + if snapshot is None: + report["errors"].append(f"快照不存在: {sid}") + report["ok"] = False + return report + + report["expected_digest"] = snapshot["digest"] + report["actual_digest"] = self.compute_digest(sid, store=False) + report["digest_ok"] = ( + snapshot["digest"] is not None + and snapshot["digest"] == report["actual_digest"] + ) + if snapshot["digest"] is None: + report["errors"].append("快照没有记录摘要,无法判断是否被改动") + elif not report["digest_ok"]: + report["errors"].append( + f"快照摘要不一致:记录 {snapshot['digest']}," + f"实际 {report['actual_digest']}" + ) + + rows = self.conn.execute( + """ + SELECT f.path, f.file_size, f.content_hash, + b.hash AS blob_hash, b.size AS blob_size, b.content, + b.freq_blob, b.compress_algo + FROM files f LEFT JOIN blobs b ON b.hash = f.content_hash + WHERE f.snapshot_id = ? AND f.is_directory = 0 + ORDER BY f.path + """, + (sid,), + ).fetchall() + + for row in rows: + report["files_checked"] += 1 + path = row["path"] + if row["content_hash"] is None: + report["errors"].append(f"{path}: content_hash 为空") + continue + if row["blob_hash"] is None: + report["errors"].append( + f"{path}: blob 缺失(hash={row['content_hash']})" + ) + continue + if row["blob_size"] != row["file_size"]: + report["errors"].append( + f"{path}: 大小不一致(files={row['file_size']}," + f"blobs={row['blob_size']})" + ) + continue + if not check_content: + continue + content = compression.decompress_with( + row["compress_algo"], row["freq_blob"], row["content"] + ) + report["content_checked"] += 1 + actual = hashlib.sha256(content).hexdigest() + if actual != row["content_hash"]: + report["errors"].append( + f"{path}: 内容哈希不匹配(记录 {row['content_hash']},实际 {actual})" + ) + + report["ok"] = not report["errors"] + return report + + @_writable + def sign(self, key, key_id=None, snapshot_id=None): + """用 HMAC-SHA256 给快照摘要签名,返回签名字符串。 + + 注意这是**对称**签名:验证方必须持有同一个密钥。需要非对称信任链的话, + 请把 compute_digest() 的结果交给 GPG / cosign 之类的外部工具签名。 + """ + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + if sid is None: + raise FileNotFoundError("库里没有任何快照,无法签名") + digest = self.compute_digest(sid, store=True) + signature = hmac.new( + _key_bytes(key), digest.encode("utf-8"), hashlib.sha256 + ).hexdigest() + self.conn.execute( + """ + INSERT INTO signatures (snapshot_id, algorithm, key_id, signature, signed_time) + VALUES (?, 'hmac-sha256', ?, ?, ?) + """, + (sid, key_id, signature, _db_time(datetime.now())), + ) + self.conn.commit() + return signature + + def list_signatures(self, snapshot_id=None): + """列出某快照上的签名记录(最新在前)。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + if sid is None: + return [] + rows = self.conn.execute( + "SELECT * FROM signatures WHERE snapshot_id = ? ORDER BY id DESC", + (sid,), + ).fetchall() + return [self._parse_row_datetime(row) for row in rows] + + def verify_signature(self, key, snapshot_id=None, key_id=None): + """校验签名,返回报告(可在只读连接上执行)。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + sid = snapshot_id if snapshot_id is not None else self._active_snapshot() + report = { + "ok": False, + "snapshot_id": sid, + "key_id": None, + "signature": None, + "expected": None, + "signed_time": None, + "errors": [], + } + if sid is None: + report["errors"].append("库里没有任何快照") + return report + + sql = "SELECT * FROM signatures WHERE snapshot_id = ?" + params = [sid] + if key_id is not None: + sql += " AND key_id = ?" + params.append(key_id) + sql += " ORDER BY id DESC" + row = self.conn.execute(sql, params).fetchone() + if row is None: + report["errors"].append("该快照没有签名记录") + return report + + digest = self.compute_digest(sid, store=False) + expected = hmac.new( + _key_bytes(key), digest.encode("utf-8"), hashlib.sha256 + ).hexdigest() + report.update( + { + "key_id": row["key_id"], + "signature": row["signature"], + "expected": expected, + "signed_time": row["signed_time"], + } + ) + if hmac.compare_digest(row["signature"], expected): + report["ok"] = True + else: + report["errors"].append( + "签名不匹配:要么密钥不对,要么快照内容在签名之后被改动过" + ) + return report + + # ------------------------------------------------------------------ + # 移动 / 创建 / 删除 + # ------------------------------------------------------------------ + + @staticmethod + def _parent_of(path): + """返回规范化路径的父路径(顶层为 '')。""" + if "/" not in path: + return "" + return path.rsplit("/", 1)[0] + + @staticmethod + def _basename_of(path): + return path.rsplit("/", 1)[-1] + + def _require_parent_dir(self, path, display=None): + """确认目标路径的父目录在库内存在。""" + parent = self._parent_of(path) + if parent and not self.is_dir(parent): + raise NotADirectoryError( + f"目标的父目录不存在: {parent}(完整路径: {display or path})" + ) + + def _insert_directory_row(self, cursor, dir_path): + """插入一条目录记录(dir_path 必须是已规范化的路径)。""" + now = datetime.now() + cursor.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, + created_time, modified_time, permissions) + VALUES (?, ?, ?, ?, 1, ?, ?, ?) + """, + ( + self._ensure_snapshot(), + dir_path, + self._basename_of(dir_path), + self._parent_of(dir_path), + _db_time(now), + _db_time(now), + 0o755, + ), + ) + + @_writable + def mv_file(self, src, dest): + """移动文件(不涉及目录)。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + src_path = self._normalize(src) + dest_path = self._normalize(dest) + + if not dest_path: + raise ValueError("目标路径不能为空") + if src_path == dest_path: + return + if not self.exists(src_path): + raise FileNotFoundError(f"来源不存在: {src}") + if self.is_dir(src_path): + raise IsADirectoryError(f"来源是目录,请使用 mv_dir: {src}") + if self.exists(dest_path): + raise FileExistsError(f"目标已存在: {dest}") + self._require_parent_dir(dest_path, dest) + + snapshot_id = self._ensure_snapshot() + cursor = self.conn.cursor() + try: + cursor.execute( + """ + UPDATE files + SET path = ?, name = ?, parent_path = ? + WHERE snapshot_id = ? AND path = ? + """, + ( + dest_path, + self._basename_of(dest_path), + self._parent_of(dest_path), + snapshot_id, + src_path, + ), + ) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + + @_writable + def mk_dir(self, path): + """在虚拟文件系统里创建一个空目录。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + dir_path = self._normalize(path) + if not dir_path: + raise ValueError("目录路径不能为空") + if self.exists(dir_path): + raise FileExistsError(f"路径已存在: {path}") + self._require_parent_dir(dir_path, path) + + cursor = self.conn.cursor() + try: + self._insert_directory_row(cursor, dir_path) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + + @_writable + def mv_dir(self, src, dest): + """移动目录(连同其下所有内容);src 为 "/" 表示移动根目录下的全部内容。""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + + src_path = self._normalize(src) + dest_path = self._normalize(dest) + if not dest_path: + raise ValueError("目标路径不能为空") + if src_path == dest_path: + return + # 不能移入自身:改名后 LIKE 前缀会二次命中,把路径越改越长 + if src_path and dest_path.startswith(src_path + "/"): + raise ValueError(f"不能把目录移动到它自己的子目录里: {src} -> {dest}") + + if src_path: + if not self.exists(src_path): + raise FileNotFoundError(f"来源不存在: {src}") + if not self.is_dir(src_path): + raise NotADirectoryError(f"来源不是目录: {src}") + elif "/" in dest_path: + # 根目录内容整体移动时,目标若在子目录里就成了自引用 + raise ValueError(f"移动根目录下的全部内容时,目标必须是顶层目录: {dest}") + + if self.exists(dest_path): + raise FileExistsError(f"目标已存在: {dest}") + self._require_parent_dir(dest_path, dest) + + snapshot_id = self._ensure_snapshot() + cursor = self.conn.cursor() + try: + if not src_path: + # 先建目标目录行,再过河拆桥地把所有内容搬到它下面 + self._insert_directory_row(cursor, dest_path) + cursor.execute( + """ + UPDATE files + SET path = ? || '/' || path, + parent_path = CASE + WHEN parent_path IS NULL OR parent_path = '' OR parent_path = '/' + THEN ? + ELSE ? || '/' || parent_path + END + WHERE snapshot_id = ? AND path != '' AND path != ? + """, + (dest_path, dest_path, dest_path, snapshot_id, dest_path), + ) + else: + # 先改目录自身 + cursor.execute( + """ + UPDATE files + SET path = ?, name = ?, parent_path = ? + WHERE snapshot_id = ? AND path = ? + """, + ( + dest_path, + self._basename_of(dest_path), + self._parent_of(dest_path), + snapshot_id, + src_path, + ), + ) + # 再改它下面的内容;必须用 '/' 做前缀边界, + # 否则移动 doc 会把 docs 一起改掉 + cursor.execute( + """ + UPDATE files + SET path = ? || SUBSTR(path, ?), + parent_path = ? || SUBSTR(parent_path, ?) + WHERE snapshot_id = ? AND path LIKE ? ESCAPE '\\' + """, + ( + dest_path, + len(src_path) + 1, + dest_path, + len(src_path) + 1, + snapshot_id, + self._escape_like(src_path) + "/%", + ), + ) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + + @_writable + def remove(self, path): + """删除文件或目录(目录会连同其下内容一起删除)""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + target = self._normalize(path) + if not target: + raise ValueError("不能删除根目录") + if not self.exists(target): + raise FileNotFoundError(f"路径不存在: {path}") + if self.is_dir(target): + self._remove_directory(target) + else: + self._remove_file(target) def _remove_directory(self, path): - """删除目录""" + """删除当前快照里的目录及其下所有内容""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + path = self._normalize(path) + snapshot_id = self._ensure_snapshot() + cursor = self.conn.cursor() + try: + cursor.execute( + """ + DELETE FROM files + WHERE snapshot_id = ? AND (path = ? OR path LIKE ? ESCAPE '\\') + """, + (snapshot_id, path, self._escape_like(path) + "/%"), + ) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + self.recount() + self.gc_blobs() + + def _remove_file(self, path): + """删除当前快照里的单个文件""" + if self.conn is None: + raise ConnectionError("文件系统尚未连接") + snapshot_id = self._ensure_snapshot() + cursor = self.conn.cursor() + try: + cursor.execute( + "DELETE FROM files WHERE snapshot_id = ? AND path = ?", + (snapshot_id, self._normalize(path)), + ) + self.conn.commit() + except Exception: + self.conn.rollback() + raise + self.recount() + self.gc_blobs() + + @_writable + def gc_blobs(self): + """删除不再被任何快照引用的 blob,返回删除条数。 + + 多快照下只要还有任一快照引用就保留,所以删掉一个版本不会影响其它版本。 + """ if self.conn is None: raise ConnectionError("文件系统尚未连接") cursor = self.conn.cursor() cursor.execute( """ - DELETE FROM files - WHERE path LIKE ? || '%' - """, - (path.lstrip("/"),), + DELETE FROM blobs + WHERE hash NOT IN ( + SELECT content_hash FROM files WHERE content_hash IS NOT NULL + ) + """ ) - self.total_files -= cursor.rowcount - self.total_size -= cursor.rowcount - self.conn.commit() - - def _remove_file(self, path): - """删除文件""" - if self.conn is None: - raise ConnectionError("文件系统尚未连接") - cursor = self.conn.cursor() - cursor.execute("DELETE FROM files WHERE path = ?", (path.lstrip("/"),)) - self.total_files -= cursor.rowcount - self.total_size -= cursor.rowcount + deleted = cursor.rowcount self.conn.commit() + return deleted def exists(self, path): """检查文件是否存在""" @@ -457,8 +1610,52 @@ class SQLiteVFS: if self.conn: self.conn.close() - def export_file(self, vfs_path, local_path): - """从虚拟文件系统导出文件到本地文件系统""" + # ------------------------------------------------------------------ + # 导出到本地文件系统 + # ------------------------------------------------------------------ + + def _ensure_parent_dir(self, local_path): + """创建本地目标路径的父目录。 + + local_path 可能是不带目录部分的纯文件名(此时 dirname 为 ''), + 不能直接拿去做 makedirs。 + """ + parent = os.path.dirname(local_path) + if parent: + os.makedirs(parent, exist_ok=True) + + def _apply_timestamps(self, local_path, file_info, tolerant): + """把库内记录的时间戳写回本地文件。""" + for field, label in (("created_time", "创建时间"), ("modified_time", "修改时间")): + value = file_info[field] + if not value: + continue + try: + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + timestamp = value.timestamp() + if field == "modified_time": + # 保持访问时间不变,只修改修改时间 + os.utime(local_path, (os.path.getatime(local_path), timestamp)) + else: + os.utime(local_path, (timestamp, timestamp)) + except Exception as e: + if not tolerant: + raise + print(f"警告: 无法设置{label} {local_path}: {e}") + + def _apply_permissions(self, local_path, file_info, tolerant): + permissions = file_info["permissions"] + if not permissions: + return + try: + os.chmod(local_path, permissions) + except Exception as e: + if not tolerant: + raise + print(f"警告: 无法设置权限 {local_path}: {e}") + + def _export_file(self, vfs_path, local_path, tolerant): try: file_info = self.get_file_info(vfs_path) if not file_info: @@ -468,7 +1665,7 @@ class SQLiteVFS: raise IsADirectoryError(f"无法导出目录: {vfs_path}") # 确保目标目录存在 - os.makedirs(os.path.dirname(local_path), exist_ok=True) + self._ensure_parent_dir(local_path) # 读取文件内容 content = self.read_file(vfs_path) @@ -478,55 +1675,41 @@ class SQLiteVFS: f.write(content) # 恢复文件属性(时间戳和权限) - if file_info["created_time"]: - if isinstance(file_info["created_time"], str): - # 如果是字符串,转换为datetime对象 - from datetime import datetime - - created_time = datetime.fromisoformat( - file_info["created_time"].replace("Z", "+00:00") - ) - else: - created_time = file_info["created_time"] - created_timestamp = created_time.timestamp() - os.utime(local_path, (created_timestamp, created_timestamp)) - if file_info["modified_time"]: - if isinstance(file_info["modified_time"], str): - # 如果是字符串,转换为datetime对象 - from datetime import datetime - - modified_time = datetime.fromisoformat( - file_info["modified_time"].replace("Z", "+00:00") - ) - else: - modified_time = file_info["modified_time"] - modified_timestamp = modified_time.timestamp() - # 保持访问时间不变,只修改修改时间 - current_atime = os.path.getatime(local_path) - os.utime(local_path, (current_atime, modified_timestamp)) - - if file_info["permissions"]: - os.chmod(local_path, file_info["permissions"]) + self._apply_timestamps(local_path, file_info, tolerant) + self._apply_permissions(local_path, file_info, tolerant) print(f"导出成功: {vfs_path} -> {local_path}") return True except Exception as e: - print(f"导出文件失败: {e}") - import traceback - - traceback.print_exc() # 打印详细错误信息 + print(f"导出文件失败 {vfs_path}: {e}") return False - def export_directory(self, vfs_path, local_path): + def export_file(self, vfs_path, local_path): + """从虚拟文件系统导出文件到本地文件系统。 + + 时间戳/权限设置失败也算导出失败(返回 False)。 + """ + return self._export_file(vfs_path, local_path, tolerant=False) + + def export_file_safe(self, vfs_path, local_path): + """导出文件,但时间戳/权限设置失败只告警,保证内容本身导出成功。""" + return self._export_file(vfs_path, local_path, tolerant=True) + + def _export_one(self, vfs_path, local_path, ignore_timestamps): + if ignore_timestamps: + return self.export_file_safe(vfs_path, local_path) + return self.export_file(vfs_path, local_path) + + def export_directory(self, vfs_path, local_path, ignore_timestamps=False): """从虚拟文件系统导出整个目录到本地文件系统""" try: # 处理根目录的特殊情况 - if vfs_path == "/": + if not self._normalize(vfs_path): # 检查根目录是否存在 if not self._has_root_directory(): # 如果根目录不存在,直接导出根目录下的所有内容 - return self._export_root_contents(local_path) + return self._export_root_contents(local_path, ignore_timestamps) if not self.is_dir(vfs_path): # 检查是否是有效的目录路径 @@ -534,48 +1717,47 @@ class SQLiteVFS: raise NotADirectoryError(f"路径不是目录: {vfs_path}") else: # 可能是根目录的另一种表示 - return self._export_root_contents(local_path) + return self._export_root_contents(local_path, ignore_timestamps) # 创建本地目录 os.makedirs(local_path, exist_ok=True) # 遍历目录内容 - items = self.list_directory(vfs_path) exported_count = 0 - for item in items: - item_vfs_path = item["path"] + for item in self.list_directory(vfs_path): item_local_path = os.path.join(local_path, item["name"]) if item["is_directory"]: # 递归导出子目录 - count = self.export_directory(item_vfs_path, item_local_path) - exported_count += count - else: - # 导出文件 - if self.export_file_safe(item_vfs_path, item_local_path): - exported_count += 1 + exported_count += self.export_directory( + item["path"], item_local_path, ignore_timestamps + ) + elif self._export_one(item["path"], item_local_path, ignore_timestamps): + exported_count += 1 print(f"目录导出完成: {vfs_path} -> {local_path} ({exported_count} 个项目)") return exported_count except Exception as e: - print(f"导出目录失败: {e}") + print(f"导出目录失败 {vfs_path}: {e}") return 0 def _has_root_directory(self): - """检查是否存在根目录记录""" + """检查当前快照里是否存在根目录记录""" if self.conn is None: return False - cursor = self.conn.cursor() - # 检查是否有路径为 "" 或 "/" 的目录 - cursor.execute( - "SELECT COUNT(*) as count FROM files WHERE path = '' OR path = '/'" - ) - result = cursor.fetchone() - return result["count"] > 0 + sid = self._active_snapshot() + if sid is None: + return False + row = self.conn.execute( + "SELECT COUNT(*) AS count FROM files " + "WHERE snapshot_id = ? AND (path = '' OR path = '/')", + (sid,), + ).fetchone() + return row["count"] > 0 - def _export_root_contents(self, local_path): + def _export_root_contents(self, local_path, ignore_timestamps=False): """导出根目录下的所有内容""" try: # 创建本地目录 @@ -584,31 +1766,31 @@ class SQLiteVFS: # 获取所有父路径为空的文件/目录(这些就是根目录下的内容) if self.conn is None: return 0 + sid = self._active_snapshot() + if sid is None: + return 0 - cursor = self.conn.cursor() - cursor.execute( - """ - SELECT * FROM files - WHERE parent_path IS NULL OR parent_path = '' OR parent_path = '/' - ORDER BY is_directory DESC, name ASC - """ - ) + rows = self.conn.execute( + self._select_file_sql( + "snapshot_id = ? AND (parent_path IS NULL OR parent_path = '' " + "OR parent_path = '/')" + ) + + " ORDER BY is_directory DESC, name ASC", + (sid,), + ).fetchall() - items = cursor.fetchall() exported_count = 0 - for item in items: - item_vfs_path = item["path"] + for item in rows: item_local_path = os.path.join(local_path, item["name"]) if item["is_directory"]: # 递归导出子目录 - count = self.export_directory(item_vfs_path, item_local_path) - exported_count += count - else: - # 导出文件 - if self.export_file(item_vfs_path, item_local_path): - exported_count += 1 + exported_count += self.export_directory( + item["path"], item_local_path, ignore_timestamps + ) + elif self._export_one(item["path"], item_local_path, ignore_timestamps): + exported_count += 1 print(f"根目录导出完成: -> {local_path} ({exported_count} 个项目)") return exported_count @@ -621,16 +1803,16 @@ class SQLiteVFS: """检查是否是有效的目录路径""" if self.conn is None: return False - cursor = self.conn.cursor() - # 检查是否有以此路径为父路径的文件/目录 - cursor.execute( - "SELECT COUNT(*) as count FROM files WHERE parent_path = ?", - (path.lstrip("/"),), - ) - result = cursor.fetchone() - return result["count"] > 0 + sid = self._active_snapshot() + if sid is None: + return False + row = self.conn.execute( + "SELECT COUNT(*) AS count FROM files WHERE snapshot_id = ? AND parent_path = ?", + (sid, self._normalize(path)), + ).fetchone() + return row["count"] > 0 - def export_filesystem(self, export_root): + def export_filesystem(self, export_root, ignore_timestamps=False): """导出整个文件系统到指定目录""" try: print(f"开始导出文件系统到: {export_root}") @@ -639,7 +1821,7 @@ class SQLiteVFS: os.makedirs(export_root, exist_ok=True) # 从根目录开始导出 - total_exported = self.export_directory("/", export_root) + total_exported = self.export_directory("/", export_root, ignore_timestamps) print(f"文件系统导出完成! 共导出 {total_exported} 个文件/目录") return total_exported @@ -648,77 +1830,11 @@ class SQLiteVFS: print(f"导出文件系统失败: {e}") return 0 - def export_file_safe(self, vfs_path, local_path): - """安全导出文件 - 即使时间戳处理失败也能导出文件内容""" - try: - file_info = self.get_file_info(vfs_path) - if not file_info: - raise FileNotFoundError(f"虚拟文件系统中找不到文件: {vfs_path}") - - if file_info["is_directory"]: - raise IsADirectoryError(f"无法导出目录: {vfs_path}") - - # 确保目标目录存在 - os.makedirs(os.path.dirname(local_path), exist_ok=True) - - # 读取文件内容 - content = self.read_file(vfs_path) - - # 写入本地文件 - with open(local_path, "wb") as f: - f.write(content) - - # 尝试设置时间戳,但即使失败也不影响文件导出 - try: - if file_info["created_time"]: - if isinstance(file_info["created_time"], str): - from datetime import datetime - - created_time = datetime.fromisoformat( - file_info["created_time"].replace("Z", "+00:00") - ) - else: - created_time = file_info["created_time"] - created_timestamp = created_time.timestamp() - os.utime(local_path, (created_timestamp, created_timestamp)) - except Exception as e: - print(f"警告: 无法设置创建时间 {vfs_path}: {e}") - - try: - if file_info["modified_time"]: - if isinstance(file_info["modified_time"], str): - from datetime import datetime - - modified_time = datetime.fromisoformat( - file_info["modified_time"].replace("Z", "+00:00") - ) - else: - modified_time = file_info["modified_time"] - modified_timestamp = modified_time.timestamp() - current_atime = os.path.getatime(local_path) - os.utime(local_path, (current_atime, modified_timestamp)) - except Exception as e: - print(f"警告: 无法设置修改时间 {vfs_path}: {e}") - - try: - if file_info["permissions"]: - os.chmod(local_path, file_info["permissions"]) - except Exception as e: - print(f"警告: 无法设置权限 {vfs_path}: {e}") - - print(f"导出成功: {vfs_path} -> {local_path}") - return True - - except Exception as e: - print(f"导出文件失败 {vfs_path}: {e}") - return False - # 使用示例 def demo_usage(): # 创建文件系统实例 vfs = SQLiteVFS("project.bin") - vfs.connect() # 获取统计信息 stats = vfs.get_stats() diff --git a/src/sqlite_vfs/folder_packer.py b/src/sqlite_vfs/folder_packer.py index 1383a11..98286e2 100644 --- a/src/sqlite_vfs/folder_packer.py +++ b/src/sqlite_vfs/folder_packer.py @@ -1,12 +1,48 @@ #!/usr/bin/env python3 """ 文件夹打包工具 - 将文件夹打包为SQLite虚拟文件系统 + +两种打包方式: + +* **全量**(默认):清空整库后重新写入,语义与历史版本一致。 +* **增量**(``incremental=True``):只写变化的条目,磁盘上已消失的路径会被删掉。 + 变更判定默认用 ``mtime + size``(不读文件内容,快), + 传 ``rehash=True`` 则按 sha256 逐文件比对(可靠,但要把文件都读一遍)。 + +配合内容寻址存储,增量还有额外好处:内容没变的文件即使被重新扫描, +也不会重新压缩或重复存储(blob 命中即跳过)。 """ +import fnmatch import os from datetime import datetime from .core import SQLiteVFS +#: 删除过期条目时每条 SQL 里放多少个占位符(避免超过 SQLite 的参数上限) +_DELETE_CHUNK = 400 + +#: mtime 比较的容差(秒)。跨文件系统的时间戳精度不一致,留一点余量。 +_MTIME_TOLERANCE = 1.0 + + +def _source_root(abs_path, rel_path): + """反推源根目录,使 os.path.join(source_root, rel_path) == abs_path。""" + root = os.path.abspath(abs_path) + parts = rel_path.replace("\\", "/").strip("/").split("/") + for _ in parts: + root = os.path.dirname(root) + return root + + +def _hash_file(abs_path): + import hashlib + + digest = hashlib.sha256() + with open(abs_path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + class FolderPacker: def __init__( @@ -17,87 +53,268 @@ class FolderPacker: self.total_files = 0 self.total_size = 0 self.fs_name = fs_name + #: 上一次增量打包的统计(新增/更新/未变/删除) + self.last_stats = {} + #: 本次遍历中被排除模式跳过的文件数 + self.excluded_count = 0 def should_exclude(self, filepath): - """检查文件是否应该被排除""" - filename = os.path.basename(filepath) + """检查文件是否应该被排除。 + + 模式按 glob 语义(fnmatch)匹配,同时作用于相对路径和文件名: + `*.tmp` 只排除 .tmp 文件,`vendor` 会排除整个 vendor 目录。 + """ + rel_path = filepath.replace("\\", "/").strip("/") + name = rel_path.rsplit("/", 1)[-1] for pattern in self.exclude_patterns: - if pattern in filename: + if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(rel_path, pattern): return True return False - def pack_folder(self, source_folder): - """打包整个文件夹""" - print(f"开始打包文件夹: {source_folder}") + # ------------------------------------------------------------------ + # 入口 + # ------------------------------------------------------------------ - # 记录开始时间 + def pack_folder(self, source_folder, incremental=False, rehash=False): + """打包文件夹。 + + 返回是否全部成功(有写入失败的条目时返回 False)。 + """ + if incremental: + return self._pack_incremental(source_folder, rehash=rehash) + return self._pack_full(source_folder) + + def _walk(self, source_folder): + """遍历源目录,逐个产出 ('dir'|'file', abs_path, rel_path)。""" + db_abspath = os.path.abspath(self.sqlite_vfs.db_path) + for root, dirs, files in os.walk(source_folder): + # 目录剪枝:被排除的目录整棵跳过 + dirs[:] = [ + d + for d in dirs + if not self.should_exclude( + os.path.relpath(os.path.join(root, d), source_folder) + ) + ] + for dir_name in dirs: + abs_path = os.path.join(root, dir_name) + yield "dir", abs_path, os.path.relpath(abs_path, source_folder) + for file_name in files: + abs_path = os.path.join(root, file_name) + rel_path = os.path.relpath(abs_path, source_folder) + if self.should_exclude(rel_path): + self.excluded_count += 1 + continue + if os.path.abspath(abs_path) == db_abspath: + print(f"跳过输出数据库自身: {rel_path}") + continue + yield "file", abs_path, rel_path + + # ------------------------------------------------------------------ + # 全量 + # ------------------------------------------------------------------ + + def _pack_full(self, source_folder): + print(f"开始打包文件夹(全量): {source_folder}") start_time = datetime.now() self.sqlite_vfs.clean() - - # 插入文件系统元数据 self.sqlite_vfs.add_vfs_metadata(self.fs_name) self.total_files = 0 self.total_size = 0 + self.excluded_count = 0 + failed = [] - # 遍历文件夹 - for root, dirs, files in os.walk(source_folder): - # 处理目录 - for dir_name in dirs: - dir_path = os.path.join(root, dir_name) - rel_path = os.path.relpath(dir_path, source_folder) + for kind, abs_path, rel_path in self._walk(source_folder): + if kind == "dir": + if not self.sqlite_vfs.add_directory(rel_path, source_folder): + failed.append(rel_path) + elif not self.sqlite_vfs.add_file(abs_path, rel_path): + failed.append(rel_path) - if self.should_exclude(rel_path): - continue - - self.sqlite_vfs.add_directory(rel_path, source_folder) - - # 处理文件 - for file_name in files: - file_path = os.path.join(root, file_name) - rel_path = os.path.relpath(file_path, source_folder) - - if self.should_exclude(rel_path): - continue - - file_size = os.path.getsize(file_path) - self.sqlite_vfs.add_file(file_path, rel_path) - self.total_size += file_size - self.total_files += 1 - - # 更新统计信息 + # 统计信息以库内实际内容为准 + self.total_files, self.total_size = self.sqlite_vfs.recount() self.sqlite_vfs.update_vfs_metadata( self.fs_name, self.total_files, self.total_size ) + self.last_stats = {"added": self.total_files, "updated": 0, "unchanged": 0, "removed": 0} - # 计算耗时 - end_time = datetime.now() - duration = (end_time - start_time).total_seconds() + if self.excluded_count: + print(f"已排除 {self.excluded_count} 个文件(匹配排除模式)") + self._report(start_time, failed) + return not failed - print(f"打包完成!") + # ------------------------------------------------------------------ + # 增量 + # ------------------------------------------------------------------ + + def _known_entries(self): + """读出当前快照已有的条目:{path: (size, mtime_epoch, content_hash)}。""" + snapshot_id = self.sqlite_vfs._active_snapshot() + if snapshot_id is None: + return {} + parse_time = self.sqlite_vfs.parse_time + known = {} + rows = self.sqlite_vfs.conn.execute( + "SELECT path, file_size, modified_time, content_hash, is_directory " + "FROM files WHERE snapshot_id = ?", + (snapshot_id,), + ).fetchall() + for row in rows: + is_dir = bool(row["is_directory"]) + mtime = None if is_dir else parse_time(row["modified_time"]) + known[row["path"]] = { + "size": None if is_dir else row["file_size"], + "mtime": mtime.timestamp() if mtime is not None else None, + "hash": None if is_dir else row["content_hash"], + "is_dir": is_dir, + } + return known + + def _is_unchanged(self, previous, abs_path, rehash): + """判断磁盘上的文件与库里记录的是否一致。""" + try: + stat = os.stat(abs_path) + except OSError: + return False + if rehash: + content_hash = previous["hash"] + if content_hash is None: + return False + try: + return _hash_file(abs_path) == content_hash + except OSError: + return False + if previous["size"] is None or previous["mtime"] is None: + return False + if previous["size"] != stat.st_size: + return False + return abs(previous["mtime"] - stat.st_mtime) <= _MTIME_TOLERANCE + + def _delete_paths(self, paths): + """从当前快照里删除一批路径(含目录及其子项)。""" + if not paths: + return 0 + snapshot_id = self.sqlite_vfs._ensure_snapshot() + cursor = self.sqlite_vfs.conn.cursor() + deleted = 0 + for start in range(0, len(paths), _DELETE_CHUNK): + chunk = paths[start : start + _DELETE_CHUNK] + placeholders = ", ".join("?" for _ in chunk) + cursor.execute( + f"DELETE FROM files WHERE snapshot_id = ? AND path IN ({placeholders})", + (snapshot_id, *chunk), + ) + deleted += cursor.rowcount + self.sqlite_vfs.conn.commit() + return deleted + + def _pack_incremental(self, source_folder, rehash=False): + mode = "sha256 全量比对" if rehash else "mtime+size 比对" + print(f"开始打包文件夹(增量,{mode}): {source_folder}") + start_time = datetime.now() + + if self.sqlite_vfs.get_stats()["snapshot_id"] is None: + # 空库也能增量:先建立元数据与默认快照 + self.sqlite_vfs.add_vfs_metadata(self.fs_name) + + known = self._known_entries() + seen = set() + added = updated = unchanged = 0 + self.excluded_count = 0 + failed = [] + + for kind, abs_path, rel_path in self._walk(source_folder): + key = self.sqlite_vfs._normalize(rel_path) + seen.add(key) + previous = known.get(key) + + if kind == "dir": + # 新目录,或者这个路径以前是文件、现在变成目录了 + if previous is None or not previous["is_dir"]: + if not self.sqlite_vfs.add_directory( + rel_path, source_folder, replace=previous is not None + ): + failed.append(rel_path) + continue + + if ( + previous is not None + and previous["is_dir"] is False + and self._is_unchanged(previous, abs_path, rehash) + ): + unchanged += 1 + continue + + # 已存在的路径必须用 replace:UNIQUE(snapshot_id, path) 不允许直接插 + if self.sqlite_vfs.add_file( + abs_path, rel_path, replace=previous is not None + ): + if previous is None: + added += 1 + else: + updated += 1 + else: + failed.append(rel_path) + + stale = sorted(set(known) - seen) + removed = self._delete_paths(stale) + + self.total_files, self.total_size = self.sqlite_vfs.recount() + self.sqlite_vfs.update_vfs_metadata( + self.fs_name, self.total_files, self.total_size + ) + self.sqlite_vfs.gc_blobs() + self.last_stats = { + "added": added, + "updated": updated, + "unchanged": unchanged, + "removed": removed, + "excluded": self.excluded_count, + } + + if self.excluded_count: + print(f"已排除 {self.excluded_count} 个文件(匹配排除模式)") + print( + f"增量结果: 新增 {added},更新 {updated},未变 {unchanged},删除 {removed}" + ) + self._report(start_time, failed) + return not failed + + # ------------------------------------------------------------------ + + def _report(self, start_time, failed): + duration = (datetime.now() - start_time).total_seconds() + if failed: + print(f"警告: 有 {len(failed)} 个条目未能写入数据库,例如: {failed[:5]}") + print("打包完成!") print(f"文件数量: {self.total_files}") print(f"总大小: {self.total_size / 1024 / 1024:.2f} MB") print(f"耗时: {duration:.2f} 秒") print(f"数据库文件: {self.sqlite_vfs.db_path}") def add_file(self, abs_path, rel_path): - """添加单个文件""" - self.sqlite_vfs.add_file(abs_path, rel_path) - self.total_size += os.path.getsize(abs_path) - self.total_files += 1 - self.sqlite_vfs.update_vfs_metadata( - self.fs_name, self.total_files, self.total_size - ) - print(f"添加文件: {rel_path}") - print(f"文件大小: {os.path.getsize(abs_path) / 1024 / 1024:.2f} MB") - print(f"数据库文件: {self.sqlite_vfs.db_path}") - self.sqlite_vfs.export_file_safe(rel_path, abs_path) - print(f"已保存到: {abs_path}") - print(f"已保存到: {self.sqlite_vfs.db_path}") + """添加单个文件。 + + 注意:这里只把文件写进数据库,绝不会去写磁盘上的源文件 + (原实现在这里反向调用了 export_file_safe,会用库内内容覆盖源文件)。 + """ + ok = self.sqlite_vfs.add_file(abs_path, rel_path) + if ok: + self.total_files, self.total_size = self.sqlite_vfs.recount() + self.sqlite_vfs.update_vfs_metadata( + self.fs_name, self.total_files, self.total_size + ) + print(f"添加文件: {rel_path}") + print(f"文件大小: {os.path.getsize(abs_path) / 1024 / 1024:.2f} MB") + print(f"数据库文件: {self.sqlite_vfs.db_path}") + return ok def add_directory(self, abs_path, rel_path): - """添加目录""" - self.sqlite_vfs.add_directory(rel_path, abs_path) - print(f"添加目录: {rel_path}") - print(f"数据库文件: {self.sqlite_vfs.db_path}") + """添加单个目录""" + ok = self.sqlite_vfs.add_directory(rel_path, _source_root(abs_path, rel_path)) + if ok: + print(f"添加目录: {rel_path}") + print(f"数据库文件: {self.sqlite_vfs.db_path}") + return ok diff --git a/src/sqlite_vfs/folder_unpacker.py b/src/sqlite_vfs/folder_unpacker.py index 295b1da..a9b2903 100644 --- a/src/sqlite_vfs/folder_unpacker.py +++ b/src/sqlite_vfs/folder_unpacker.py @@ -11,12 +11,13 @@ class FolderUnpacker: self.sqlite_vfs = sqlite_vfs def check_database(self): - """检查数据库结构和内容""" + """检查数据库结构和内容(只统计当前快照)。""" try: - if self.sqlite_vfs.conn is None: + vfs = self.sqlite_vfs + if vfs.conn is None: return False, "数据库未连接" - cursor = self.sqlite_vfs.conn.cursor() + cursor = vfs.conn.cursor() # 检查表是否存在 cursor.execute( @@ -25,63 +26,84 @@ class FolderUnpacker: if not cursor.fetchone(): return False, "files表不存在" + snapshot_id = vfs._active_snapshot() + if snapshot_id is None: + return False, "库里没有任何快照" + # 检查是否有数据 - cursor.execute("SELECT COUNT(*) as count FROM files") - result = cursor.fetchone() - file_count = result["count"] + cursor.execute( + "SELECT COUNT(*) as count FROM files WHERE snapshot_id = ?", + (snapshot_id,), + ) + file_count = cursor.fetchone()["count"] # 检查根目录内容 cursor.execute( """ - SELECT COUNT(*) as count FROM files - WHERE parent_path IS NULL OR parent_path = '' OR parent_path = '/' - """ + SELECT COUNT(*) as count FROM files + WHERE snapshot_id = ? + AND (parent_path IS NULL OR parent_path = '' OR parent_path = '/') + """, + (snapshot_id,), ) root_count = cursor.fetchone()["count"] return ( True, - f"数据库正常,共有 {file_count} 个文件/目录,根目录下有 {root_count} 个项目", + f"数据库正常,快照 {snapshot_id} 共有 {file_count} 个文件/目录," + f"根目录下有 {root_count} 个项目", ) except Exception as e: return False, f"数据库检查失败: {e}" - def unpack_filesystem(self, export_folder): - """解包整个文件系统""" + def unpack_filesystem(self, export_folder, ignore_timestamps=False): + """解包整个文件系统(当前快照)""" # 先检查数据库 status, message = self.check_database() print(f"数据库状态: {message}") if not status: - print("数据库存在问题,无法导出") - return 0 + raise RuntimeError(f"数据库存在问题,无法导出: {message}") - return self.sqlite_vfs.export_filesystem(export_folder) + return self.sqlite_vfs.export_filesystem( + export_folder, ignore_timestamps=ignore_timestamps + ) def unpack_file(self, vfs_path, local_path): """解包单个文件""" return self.sqlite_vfs.export_file(vfs_path, local_path) - def unpack_directory(self, vfs_path, local_path): + def unpack_directory(self, vfs_path, local_path, ignore_timestamps=False): """解包目录""" - return self.sqlite_vfs.export_directory(vfs_path, local_path) + return self.sqlite_vfs.export_directory( + vfs_path, local_path, ignore_timestamps=ignore_timestamps + ) def list_root(self): - """列出根目录内容""" + """列出当前快照的根目录内容""" try: - if self.sqlite_vfs.conn is None: + vfs = self.sqlite_vfs + if vfs.conn is None: return [] - cursor = self.sqlite_vfs.conn.cursor() + snapshot_id = vfs._active_snapshot() + if snapshot_id is None: + return [] + + cursor = vfs.conn.cursor() cursor.execute( - """ - SELECT * FROM files - WHERE parent_path IS NULL OR parent_path = '' OR parent_path = '/' - ORDER BY is_directory DESC, name ASC - """ + f"SELECT {', '.join(vfs.FILE_COLUMNS)} FROM files " + "WHERE snapshot_id = ? " + "AND (parent_path IS NULL OR parent_path = '' OR parent_path = '/') " + "ORDER BY is_directory DESC, name ASC", + (snapshot_id,), ) return cursor.fetchall() except Exception as e: print(f"列出根目录失败: {e}") return [] + + def list_snapshots(self): + """列出库里所有快照。""" + return self.sqlite_vfs.list_snapshots() diff --git a/src/sqlite_vfs/huffman.py b/src/sqlite_vfs/huffman.py new file mode 100644 index 0000000..9feb206 --- /dev/null +++ b/src/sqlite_vfs/huffman.py @@ -0,0 +1,243 @@ +import heapq +import struct + + +# ============================================================ +# 一、哈夫曼树 +# ============================================================ + +class Node: + __slots__ = ('char', 'freq', 'left', 'right') + + def __init__(self, char, freq, left=None, right=None): + self.char = char + self.freq = freq + self.left = left + self.right = right + + def __lt__(self, other): + return self.freq < other.freq + + +def build_huffman_tree(freq): + heap = [Node(i, f) for i, f in enumerate(freq) if f > 0] + if not heap: + return None + heapq.heapify(heap) + + while len(heap) > 1: + left = heapq.heappop(heap) + right = heapq.heappop(heap) + merged = Node(None, left.freq + right.freq, left, right) + heapq.heappush(heap, merged) + + return heap[0] + + +def build_codes(root): + codes = {} + if root is None: + return codes + if root.char is not None: + codes[root.char] = '0' + return codes + + stack = [(root, '')] + while stack: + node, prefix = stack.pop() + if node.char is not None: + codes[node.char] = prefix + else: + stack.append((node.right, prefix + '1')) + stack.append((node.left, prefix + '0')) + return codes + + +# ============================================================ +# 二、位读写 +# ============================================================ + +class BitWriter: + __slots__ = ('buf', 'cur', 'nbits') + + def __init__(self): + self.buf = bytearray() + self.cur = 0 + self.nbits = 0 + + def write_bit(self, bit): + self.cur = (self.cur << 1) | bit + self.nbits += 1 + if self.nbits == 8: + self.buf.append(self.cur) + self.cur = 0 + self.nbits = 0 + + def write_code(self, code): + for ch in code: + self.write_bit(1 if ch == '1' else 0) + + def flush(self): + if self.nbits == 0: + return 0 + pad = 8 - self.nbits + self.cur <<= pad + self.buf.append(self.cur) + self.cur = 0 + self.nbits = 0 + return pad + + +class BitReader: + __slots__ = ('data', 'pos', 'cur', 'nbits') + + def __init__(self, data): + self.data = data + self.pos = 0 + self.cur = 0 + self.nbits = 0 + + def read_bit(self): + if self.nbits == 0: + if self.pos >= len(self.data): + return None + self.cur = self.data[self.pos] + self.pos += 1 + self.nbits = 8 + self.nbits -= 1 + return (self.cur >> self.nbits) & 1 + + +# ============================================================ +# 三、核心层:处理 bytes,不碰文件 +# ============================================================ + +FREQ_SIZE = 256 * 4 +MAGIC = b'HUF1' + + +def compress_bytes(data: bytes): + """ + 输入原始字节,返回 (freq_blob, compressed_blob) + freq_blob: 1024 字节,256 个 uint32 小端 + compressed_blob: 13 字节头 + 位打包数据 + """ + # 1. 统计频率 + freq = [0] * 256 + for b in data: + freq[b] += 1 + + # 2. 频率表 blob + freq_blob = struct.pack('<256I', *freq) + + # 3. 建树与编码表 + root = build_huffman_tree(freq) + codes = build_codes(root) + + # 4. 编码 + writer = BitWriter() + for b in data: + writer.write_code(codes[b]) + pad = writer.flush() + + # 5. 组装压缩数据 blob + header = MAGIC + struct.pack('>Q', len(data)) + struct.pack('B', pad) + compressed_blob = header + bytes(writer.buf) + + return freq_blob, compressed_blob + + +def decompress_bytes(freq_blob: bytes, compressed_blob: bytes) -> bytes: + """从频率表 blob 和压缩数据 blob 还原原始字节""" + # 1. 解析频率表 + if len(freq_blob) != FREQ_SIZE: + raise ValueError(f"频率表大小不正确:{len(freq_blob)}") + freq = list(struct.unpack('<256I', freq_blob)) + + # 2. 解析压缩数据头 + if len(compressed_blob) < 13: + raise ValueError("压缩数据太短") + if compressed_blob[:4] != MAGIC: + raise ValueError("魔数不匹配") + orig_len = struct.unpack('>Q', compressed_blob[4:12])[0] + # pad = compressed_blob[12] + payload = compressed_blob[13:] + + # 3. 空文件 + if orig_len == 0: + return b'' + + # 4. 重建树 + root = build_huffman_tree(freq) + if root is None: + raise ValueError("频率表全 0,但原始长度不为 0") + + # 5. 单一字节情况 + if root.char is not None: + return bytes([root.char]) * orig_len + + # 6. 逐位解码 + reader = BitReader(payload) + out = bytearray() + node = root + while len(out) < orig_len: + bit = reader.read_bit() + if bit is None: + raise ValueError("数据提前结束") + if node is None: + raise ValueError("解码时遇到空节点") + node = node.left if bit == 0 else node.right + if node is None: + raise ValueError("解码时遇到空节点") + if node.char is not None: + out.append(node.char) + node = root + + return bytes(out) + + +# ============================================================ +# 四、文件层:包装成两个文件 +# ============================================================ + +def compress_file(input_path, freq_path, out_path): + with open(input_path, 'rb') as f: + data = f.read() + freq_blob, compressed_blob = compress_bytes(data) + with open(freq_path, 'wb') as f: + f.write(freq_blob) + with open(out_path, 'wb') as f: + f.write(compressed_blob) + + +def decompress_file(freq_path, compressed_path, out_path): + with open(freq_path, 'rb') as f: + freq_blob = f.read() + with open(compressed_path, 'rb') as f: + compressed_blob = f.read() + data = decompress_bytes(freq_blob, compressed_blob) + with open(out_path, 'wb') as f: + f.write(data) + + +# ============================================================ +# 五、测试 +# ============================================================ + +if __name__ == '__main__': + import os + + text = ("the quick brown fox jumps over the lazy dog. " * 2000).encode() + with open('demo.bin', 'wb') as f: + f.write(text) + + compress_file('demo.bin', 'demo.freq', 'demo.huf') + decompress_file('demo.freq', 'demo.huf', 'demo.out') + + with open('demo.out', 'rb') as f: + restored = f.read() + + print(f"原文件: {os.path.getsize('demo.bin')} 字节") + print(f"频率表: {os.path.getsize('demo.freq')} 字节") + print(f"压缩文件: {os.path.getsize('demo.huf')} 字节") + print("还原正确" if restored == text else "还原错误") \ No newline at end of file diff --git a/src/sqlite_vfs/migration.py b/src/sqlite_vfs/migration.py new file mode 100644 index 0000000..8ad9046 --- /dev/null +++ b/src/sqlite_vfs/migration.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""把旧格式的 .svfs 迁移到当前格式(schema v3:blobs + snapshots)。 + +格式演进 +-------- +* **v1**(最早):``files.content`` + ``files.compressed``(布尔),压缩用 zlib +* **v2**:``files.content`` + ``files.freq_blob`` + ``files.compress_algo`` +* **v3**(当前):内容移到内容寻址的 ``blobs`` 表,目录项加 ``snapshot_id`` + +迁移是**只读旧库、重建新库**的过程:先在旁边的临时文件上完整重建,校验通过后 +原子替换,并且默认先把原库复制一份成 ``.bak``。任何一步失败都不会让原库变成半成品。 + +顺带收益:迁移会按 sha256 合并重复内容,历史库里同一份内容存了多次的情况会被去重。 +""" + +import hashlib +import os +import shutil +import sqlite3 + +from . import compression +from .core import SCHEMA_VERSION + +#: 迁移过程中的临时文件后缀 +TEMP_SUFFIX = ".migrating" +BACKUP_SUFFIX = ".bak" + +V3_TABLES = """ +CREATE TABLE IF NOT EXISTS filesystem_metadata ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + schema_version INTEGER NOT NULL DEFAULT 3 +); +CREATE TABLE IF NOT EXISTS blobs ( + hash TEXT PRIMARY KEY, + size INTEGER NOT NULL, + stored_size INTEGER NOT NULL, + content BLOB NOT NULL, + freq_blob BLOB, + compress_algo TEXT NOT NULL DEFAULT 'none' +); +CREATE TABLE IF NOT EXISTS snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_time DATETIME, + parent_id INTEGER, + total_files INTEGER DEFAULT 0, + total_size INTEGER DEFAULT 0, + digest TEXT +); +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + path TEXT NOT NULL, + name TEXT NOT NULL, + parent_path TEXT, + is_directory BOOLEAN NOT NULL DEFAULT 0, + file_size INTEGER DEFAULT 0, + created_time DATETIME, + modified_time DATETIME, + permissions INTEGER DEFAULT 420, + content_hash TEXT, + UNIQUE (snapshot_id, path) +); +CREATE TABLE IF NOT EXISTS signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + algorithm TEXT NOT NULL, + key_id TEXT, + signature TEXT NOT NULL, + signed_time DATETIME +); +CREATE INDEX IF NOT EXISTS idx_files_snapshot_parent ON files(snapshot_id, parent_path); +CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash); +""" + + +def _table_exists(conn, name): + return ( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + is not None + ) + + +def _columns(conn, table): + return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def detect_schema_version(db_path): + """识别库的格式版本:3 = 当前,2 / 1 = 旧格式,None = 不是本工具的库。""" + if not os.path.exists(db_path): + raise FileNotFoundError(f"数据库不存在: {db_path}") + conn = sqlite3.connect(f"file:{os.path.abspath(db_path).replace(chr(92), '/')}?mode=ro", uri=True) + try: + if not _table_exists(conn, "files"): + return None + if _table_exists(conn, "blobs") and "content_hash" in _columns(conn, "files"): + return 3 + columns = _columns(conn, "files") + if "compress_algo" in columns: + return 2 + if "compressed" in columns: + return 1 + return None + finally: + conn.close() + + +def _backup_path(db_path): + """挑一个不覆盖既有备份的名字。""" + candidate = db_path + BACKUP_SUFFIX + index = 1 + while os.path.exists(candidate): + candidate = f"{db_path}{BACKUP_SUFFIX}.{index}" + index += 1 + return candidate + + +def _legacy_algo(row, columns): + """把旧库的列映射成算法名。""" + if "compress_algo" in columns: + return row["compress_algo"] or "none" + if "compressed" in columns: + return "zlib" if row["compressed"] else "none" + return "none" + + +def migrate(db_path, backup=None, snapshot_name=None, progress=None, output=None): + """把旧格式的库升级到 schema v3。 + + 参数: + db_path: 要迁移的数据库路径(只读打开) + backup: 是否先备份。默认 None = 自动:原地迁移时备份,指定 output 时不备份 + (因为原库本身就是备份) + snapshot_name: 迁移后默认快照的名字,默认沿用库里的文件系统名 + progress: 可选回调 progress(done, total),用于汇报进度 + output: 输出到新路径而不是原地替换。适合原库位于只读介质、 + 或想先比对再切换的场景;给了 output 就绝不改动原库 + + 返回汇总 dict(迁移是否发生、备份路径、输出路径、条目数、去重省下的字节数等)。 + """ + db_path = os.path.abspath(db_path) + version = detect_schema_version(db_path) + if version is None: + raise ValueError(f"{db_path} 不是虚拟文件系统数据库,无法迁移") + if version == SCHEMA_VERSION: + return { + "migrated": False, + "reason": f"已经是 schema v{SCHEMA_VERSION},无需迁移", + "schema_version": version, + "output": output, + } + + if backup is None: + backup = output is None + + destination = os.path.abspath(output) if output else db_path + if output and os.path.exists(destination): + raise FileExistsError(f"输出路径已存在,请换一个: {destination}") + + backup_path = None + if backup: + backup_path = _backup_path(db_path) + shutil.copy2(db_path, backup_path) + + temp_path = destination + TEMP_SUFFIX + if os.path.exists(temp_path): + os.remove(temp_path) + + source = sqlite3.connect( + f"file:{db_path.replace(os.sep, '/')}?mode=ro", uri=True + ) + source.row_factory = sqlite3.Row + target = sqlite3.connect(temp_path) + target.row_factory = sqlite3.Row + try: + target.executescript(V3_TABLES) + + # 整包名称 + name = snapshot_name + if name is None: + if _table_exists(source, "filesystem_metadata"): + row = source.execute( + "SELECT name FROM filesystem_metadata ORDER BY id LIMIT 1" + ).fetchone() + name = row["name"] if row else None + name = name or "default" + target.execute( + "INSERT INTO filesystem_metadata (name, schema_version) VALUES (?, ?)", + (name, SCHEMA_VERSION), + ) + target.execute( + "INSERT INTO snapshots (id, name, created_time, parent_id, total_files, total_size)" + " VALUES (1, ?, ?, NULL, 0, 0)", + (name, None), + ) + + columns = _columns(source, "files") + has_content = "content" in columns + rows = source.execute( + "SELECT * FROM files ORDER BY path" + ).fetchall() + total = len(rows) + + blobs = set() + dedup_saved = 0 + files_migrated = 0 + for done, row in enumerate(rows, start=1): + row = dict(row) + content_hash = None + if not row["is_directory"] and has_content and row["content"] is not None: + algo = _legacy_algo(row, columns) + original = compression.decompress_with( + algo, row.get("freq_blob"), row["content"] + ) + content_hash = hashlib.sha256(original).hexdigest() + if content_hash not in blobs: + blobs.add(content_hash) + target.execute( + """ + INSERT INTO blobs + (hash, size, stored_size, content, freq_blob, compress_algo) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + content_hash, + row.get("file_size") or len(original), + len(row["content"]) + len(row.get("freq_blob") or b""), + sqlite3.Binary(row["content"]), + sqlite3.Binary(row["freq_blob"]) if row.get("freq_blob") else None, + algo, + ), + ) + else: + # 这份内容之前已经存过了,去重省下来的字节数 + dedup_saved += len(row["content"]) + len(row.get("freq_blob") or b"") + + target.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, file_size, + created_time, modified_time, permissions, content_hash) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row["path"], + row["name"], + row["parent_path"], + row["is_directory"], + row.get("file_size") or 0, + row.get("created_time"), + row.get("modified_time"), + row.get("permissions") or 420, + content_hash, + ), + ) + files_migrated += 1 + if progress is not None: + progress(done, total) + + # 统计与摘要 + stats = target.execute( + "SELECT COUNT(*) AS files, COALESCE(SUM(file_size), 0) AS size " + "FROM files WHERE snapshot_id = 1 AND is_directory = 0" + ).fetchone() + target.execute( + "UPDATE snapshots SET total_files = ?, total_size = ? WHERE id = 1", + (stats["files"], stats["size"]), + ) + target.commit() + + # 摘要由 core 的算法统一计算,避免两处实现漂移 + from .core import SQLiteVFS + + target.close() + rebuilt = SQLiteVFS(temp_path) + try: + digest = rebuilt.compute_digest(1, store=True) + finally: + rebuilt.close() + target = sqlite3.connect(temp_path) + target.row_factory = sqlite3.Row + finally: + source.close() + target.close() + + # 原子替换:到这一步新库已经是完整可用的 + os.replace(temp_path, destination) + + return { + "migrated": True, + "from_version": version, + "to_version": SCHEMA_VERSION, + "in_place": output is None, + "output": destination, + "backup": backup_path, + "files": files_migrated, + "blobs": len(blobs), + "dedup_saved": dedup_saved, + "digest": digest, + } diff --git a/src/sqlite_vfs/mime.py b/src/sqlite_vfs/mime.py new file mode 100644 index 0000000..70a2e6d --- /dev/null +++ b/src/sqlite_vfs/mime.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Content-Type 推断。 + +为什么单独一个模块:这张覆盖表被两个地方用到——FastAPI 静态文件插件 +(可选依赖)和对象发布工具(无第三方依赖)。放在插件里会让发布工具 +误引入 fastapi,所以抽出来,两边共用一份,避免两处漂移。 +""" + +import mimetypes +import os + +#: 平台相关的 mimetypes 结果不可靠:Windows 上 .mjs 会得到 text/plain +#: (浏览器会拒绝执行 ES Module)、.woff2 会得到 None。 +#: 这里对 Web 关键类型做显式覆盖,长尾类型仍交给 mimetypes。 +MIME_OVERRIDES = { + ".css": "text/css", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/x-icon", + ".js": "text/javascript", + ".json": "application/json", + ".map": "application/json", + ".mjs": "text/javascript", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + + +def guess_mime(path, default="application/octet-stream"): + """按扩展名推断 Content-Type,Web 关键类型优先用显式表。""" + ext = os.path.splitext(path)[1].lower() + if ext in MIME_OVERRIDES: + return MIME_OVERRIDES[ext] + guessed, _encoding = mimetypes.guess_type(path) + return guessed or default diff --git a/src/sqlite_vfs/publish.py b/src/sqlite_vfs/publish.py new file mode 100644 index 0000000..b8f0069 --- /dev/null +++ b/src/sqlite_vfs/publish.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""把 .svfs 按内容寻址发布成"一堆对象 + 一份清单"。 + +为什么要有这个东西 +------------------ +把 .svfs 直接放到对象存储上、让客户端按页远程读,需要 SQLite 的自定义 VFS, +而 CPython 的 sqlite3 **没有**这个 API(detail 见 README「发布到对象存储」一节)。 +实测下来那条路还不便宜:不做预读时读 1MB 要 265 次请求。 + +换个思路更简单:既然库里的内容本来就是**按 sha256 寻址**的,那就把每个 blob +直接发布成一个以 hash 命名的对象,再把目录结构导出成一份小清单。客户端: + +* 只取清单(很小)+ 它真正需要的那些对象; +* 每个对象的名字就是它的 sha256,所以能**端到端校验**(CDN 也换不掉内容); +* 内容不可变 → 缓存策略天然是"永久",重复取同一份内容直接命中缓存; +* 不需要 APSW、不需要编译扩展,普通的静态托管 / CDN 就能用。 + +导出的是**原始(解压后)内容**,所以消费方不需要知道我们用了哪种压缩算法。 +""" + +import hashlib +import json +import os +import shutil +import tempfile +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +from .core import SQLiteVFS +from .mime import guess_mime + +#: 清单格式标识与版本(消费方应当先检查这两个字段) +MANIFEST_FORMAT = "svfs-objects" +MANIFEST_VERSION = 1 +MANIFEST_NAME = "manifest.json" +BLOB_DIR = "blobs" + +#: 落盘时的默认分片层数:blobs/ab/abcdef...,避免单目录堆几十万个文件 +DEFAULT_SHARD = 2 + +#: 下载对象时的默认并发数(CDN 场景下并发收益明显) +DEFAULT_WORKERS = 8 + + +# -------------------------------------------------------------------------- +# 发布:导出对象 + 清单 +# -------------------------------------------------------------------------- + + +def _shard_key(content_hash, shard): + """对象在 blobs/ 下的相对键名。""" + if shard and len(content_hash) > shard: + return f"{content_hash[:shard]}/{content_hash}" + return content_hash + + +def build_manifest(vfs, snapshot_id=None, shard=DEFAULT_SHARD): + """生成清单 dict(不落盘,方便调用方自己签名或改造)。""" + sid = snapshot_id if snapshot_id is not None else vfs._active_snapshot() + if sid is None: + raise ValueError("库里没有任何快照") + + snapshot = None + for item in vfs.list_snapshots(): + if item["id"] == sid: + snapshot = item + break + + stats = vfs.get_stats() + entries = {} + total_size = 0 + file_count = 0 + dir_count = 0 + + for entry in vfs.all_entries(sid): + path = entry["path"] + if entry["is_directory"]: + entries[path] = {"dir": True} + dir_count += 1 + continue + file_count += 1 + total_size += entry["file_size"] or 0 + info = { + "hash": entry["content_hash"], + "size": entry["file_size"] or 0, + } + # 元数据尽量精简——清单每次冷启动都要取一次 + if entry["modified_time"]: + info["mtime"] = int(entry["modified_time"].timestamp()) + if entry["permissions"]: + info["mode"] = entry["permissions"] + try: + info["mime"] = guess_mime(path) + except Exception: + info["mime"] = "application/octet-stream" + entries[path] = info + + blobs = vfs.list_blobs() + return { + "format": MANIFEST_FORMAT, + "version": MANIFEST_VERSION, + "name": stats.get("name"), + "snapshot": { + "id": sid, + "name": stats.get("snapshot_name"), + # 用快照自己的创建时间,这样清单对同一份内容是可复现的(可 diff、可签名) + "created_time": ( + snapshot["created_time"].isoformat() + if snapshot and snapshot["created_time"] + else None + ), + "digest": stats.get("digest"), + }, + "blob_dir": BLOB_DIR, + "shard": shard, + "stats": { + "files": file_count, + "directories": dir_count, + "total_size": total_size, + "objects": len(blobs), + "objects_stored_size": sum(b["stored_size"] for b in blobs), + }, + "entries": entries, + } + + +def export_objects( + db_path, + out_dir, + snapshot_id=None, + shard=DEFAULT_SHARD, + overwrite=False, + progress=None, +): + """把库导出成"对象目录 + 清单"。 + + 产出 ``/blobs/<分片>/`` 与 ``/manifest.json``。 + 对象内容是**原始(解压后)**字节。 + """ + db_path = os.path.abspath(db_path) + out_dir = os.path.abspath(out_dir) + blob_root = os.path.join(out_dir, BLOB_DIR) + + if os.path.isdir(out_dir) and os.listdir(out_dir) and not overwrite: + raise FileExistsError( + f"输出目录非空: {out_dir}(确认要覆盖请传 overwrite=True)" + ) + os.makedirs(blob_root, exist_ok=True) + + vfs = SQLiteVFS(db_path, readonly=True, snapshot_id=snapshot_id) + try: + manifest = build_manifest(vfs, snapshot_id=snapshot_id, shard=shard) + + written = 0 + written_bytes = 0 + blobs = vfs.list_blobs() + vfs_blobs = blobs + total = len(blobs) + for index, info in enumerate(blobs, start=1): + content_hash = info["hash"] + target = os.path.join(blob_root, _shard_key(content_hash, shard)) + os.makedirs(os.path.dirname(target), exist_ok=True) + + if os.path.exists(target) and not overwrite: + written += 1 + written_bytes += os.path.getsize(target) + else: + content = vfs.read_blob(content_hash, verify_hash=True) + # 先写临时文件再改名:中断不会留下半截对象 + fd, temp = tempfile.mkstemp(dir=os.path.dirname(target)) + try: + with os.fdopen(fd, "wb") as f: + f.write(content) + os.replace(temp, target) + except Exception: + if os.path.exists(temp): + os.remove(temp) + raise + written += 1 + written_bytes += len(content) + if progress is not None: + progress(index, total) + finally: + vfs.close() + + manifest_path = os.path.join(out_dir, MANIFEST_NAME) + payload = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + with open(manifest_path, "w", encoding="utf-8") as f: + f.write(payload) + + return { + "out_dir": out_dir, + "manifest": manifest_path, + "manifest_bytes": len(payload.encode("utf-8")), + "objects": written, + "objects_bytes": written_bytes, + "files": manifest["stats"]["files"], + "directories": manifest["stats"]["directories"], + # 三个口径必须分开,否则会把"去重"和"压缩"混成一个数 + "unique_bytes": sum(b["size"] for b in vfs_blobs), + "stored_bytes": sum(b["stored_size"] for b in vfs_blobs), + # 去重省下 = 原始总大小 - 去重后的原始字节数(与压缩无关) + "dedup_saved": max( + 0, + manifest["stats"]["total_size"] + - sum(b["size"] for b in vfs_blobs), + ), + # 压缩省下 = 去重后的原始字节数 - 库内实际占用 + "compression_saved": max( + 0, + sum(b["size"] for b in vfs_blobs) + - sum(b["stored_size"] for b in vfs_blobs), + ), + "snapshot_id": manifest["snapshot"]["id"], + "digest": manifest["snapshot"]["digest"], + } + + +# -------------------------------------------------------------------------- +# 客户端:取清单 + 取对象 + 校验 + 落盘 +# -------------------------------------------------------------------------- + + +def default_cache_dir(): + """默认的对象缓存目录(跨包、跨项目复用,因为内容按 hash 寻址)。""" + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_CACHE_HOME") or os.path.join( + os.path.expanduser("~"), ".cache" + ) + return os.path.join(base, "svfs-objects") + + +def _is_url(value): + return value.startswith(("http://", "https://")) + + +def _read_url(url, timeout=30): + request = urllib.request.Request(url, headers={"User-Agent": "svfs-fetch/1"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def load_manifest(source, timeout=30): + """读取清单。source 可以是本地路径,也可以是 http(s) URL。""" + if _is_url(source): + raw = _read_url(source, timeout=timeout) + else: + with open(source, "rb") as f: + raw = f.read() + manifest = json.loads(raw.decode("utf-8")) + if manifest.get("format") != MANIFEST_FORMAT: + raise ValueError( + f"不是 {MANIFEST_FORMAT} 清单(format={manifest.get('format')!r})" + ) + if manifest.get("version") != MANIFEST_VERSION: + raise ValueError( + f"清单版本不支持: {manifest.get('version')}(本版本支持 {MANIFEST_VERSION})" + ) + return manifest + + +class FetchStats: + def __init__(self): + self.files = 0 + self.directories = 0 + self.bytes_downloaded = 0 + self.objects_downloaded = 0 + self.objects_from_cache = 0 + self.verified = 0 + self.skipped = 0 + + def as_dict(self): + return dict(self.__dict__) + + +class ObjectFetcher: + """按清单把对象取到本地,逐个校验 sha256,然后铺成目录树。 + + 参数: + manifest: 已加载的清单 dict + base_url: 对象与清单的基地址;本地清单时省略(从清单所在目录取对象) + cache_dir: 对象缓存目录。内容按 hash 寻址,所以缓存可以跨包长期复用; + 传 None 表示不落缓存(每次都重新下载) + workers: 并发下载数 + verify: 是否校验 sha256(默认校验;缓存命中也会校验,失败则重新下载) + """ + + def __init__( + self, + manifest, + base_url=None, + local_root=None, + cache_dir=None, + workers=DEFAULT_WORKERS, + verify=True, + link=False, + timeout=30, + progress=None, + ): + self.manifest = manifest + self.base_url = base_url.rstrip("/") if base_url else None + #: 清单是本地文件时,默认从它所在目录读对象(把本地目录当"对象存储"用) + self.local_root = os.path.abspath(local_root) if local_root else None + self.cache_dir = cache_dir + self.workers = max(1, workers) + self.verify = verify + self.link = link + self.timeout = timeout + self.progress = progress + # 统计一律由主线程在 pool.map 之后累加,所以不需要锁 + self._done = 0 + + if self.cache_dir: + os.makedirs(self.cache_dir, exist_ok=True) + + # -- 清单查询 ------------------------------------------------------ + + @property + def entries(self): + return self.manifest["entries"] + + def resolve(self, paths=None): + """把 --path 选择器展开成实际要取的文件路径集合。 + + ``sub`` 与 ``sub/`` 都表示整棵子树;``sub/a.txt`` 是精确的文件。 + """ + self._selectors = [(p or "").replace("\\", "/").strip("/") for p in (paths or [])] + if not paths: + return { + path for path, info in self.entries.items() if not info.get("dir") + } + + selected = set() + for wanted in paths: + wanted = wanted.replace("\\", "/").strip("/") + if wanted in self.entries: + info = self.entries[wanted] + if info.get("dir"): + prefix = wanted + "/" + selected.update( + path + for path, item in self.entries.items() + if not item.get("dir") and path.startswith(prefix) + ) + else: + selected.add(wanted) + continue + # 未命中条目:当成子树前缀再试一次 + prefix = wanted + "/" + matched = { + path + for path, item in self.entries.items() + if not item.get("dir") and path.startswith(prefix) + } + if not matched: + raise FileNotFoundError(f"清单里没有这个路径: {wanted}") + selected.update(matched) + return selected + + # -- 对象获取 ------------------------------------------------------ + + def _object_key(self, content_hash): + return _shard_key(content_hash, self.manifest.get("shard", DEFAULT_SHARD)) + + def _object_url(self, content_hash): + blob_dir = self.manifest.get("blob_dir", BLOB_DIR) + return f"{self.base_url}/{blob_dir}/{self._object_key(content_hash)}" + + def _cache_path(self, content_hash): + if not self.cache_dir: + return None + return os.path.join( + self.cache_dir, + self.manifest.get("blob_dir", BLOB_DIR), + self._object_key(content_hash), + ) + + def _download(self, content_hash): + return _read_url(self._object_url(content_hash), timeout=self.timeout) + + def _read_local(self, content_hash): + blob_dir = self.manifest.get("blob_dir", BLOB_DIR) + path = os.path.join( + self.local_root, blob_dir, self._object_key(content_hash) + ) + with open(path, "rb") as f: + return f.read() + + def get_object(self, content_hash): + """取一个对象并校验。 + + 返回 ``(内容或缓存路径, 是否命中缓存, 下载字节数)``。 + 刻意不在这里改统计对象:本方法会被线程池并发调用,累加共享计数器会丢更新, + 统计一律由调用方在主线程汇总。 + """ + cached = self._cache_path(content_hash) + if cached and os.path.exists(cached): + with open(cached, "rb") as f: + data = f.read() + if not self.verify or hashlib.sha256(data).hexdigest() == content_hash: + return cached, True, 0 + # 缓存被改坏了:丢掉重下(自愈) + os.remove(cached) + + if self.base_url is not None: + data = self._download(content_hash) + elif self.local_root is not None: + data = self._read_local(content_hash) + else: + raise RuntimeError( + f"没有 base_url 也没有本地对象目录,无法获取对象 {content_hash}" + ) + actual = hashlib.sha256(data).hexdigest() + if self.verify and actual != content_hash: + raise ValueError( + f"对象校验失败: {content_hash}(实际 {actual})——" + f"服务端内容与清单不符或传输被改动" + ) + + if cached: + os.makedirs(os.path.dirname(cached), exist_ok=True) + fd, temp = tempfile.mkstemp(dir=os.path.dirname(cached)) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(temp, cached) # 中断不会留下半截缓存 + except Exception: + if os.path.exists(temp): + os.remove(temp) + raise + return cached, False, len(data) + return data, False, len(data) + + # -- 铺目录树 ------------------------------------------------------ + + def _materialize(self, source, target, mode, mtime): + """把对象内容落到目标路径。 + + 硬链接模式下**不**还原时间戳/权限:硬链接与缓存对象共用同一个 inode, + utime/chmod 会连带改到缓存,破坏"缓存对象只读且不可变"的前提。 + """ + os.makedirs(os.path.dirname(target), exist_ok=True) + + if isinstance(source, str): # 缓存里的对象文件 + if os.path.exists(target): + os.remove(target) + if self.link: + try: + os.link(source, target) + return + except OSError: + pass # 跨卷等情况退回复制 + shutil.copy2(source, target) + else: + with open(target, "wb") as f: + f.write(source) + + if mtime: + try: + os.utime(target, (mtime, mtime)) + except OSError: + pass + if mode and os.name != "nt": + try: + os.chmod(target, mode) + except OSError: + pass + + def fetch(self, out_dir, paths=None): + """取清单选中的文件并铺到 out_dir,返回统计。""" + out_dir = os.path.abspath(out_dir) + stats = FetchStats() + + wanted = self.resolve(paths) + if not wanted: + raise ValueError("清单里没有可下载的文件") + + # 目录:把选中范围内的目录都建出来(空目录也要保留) + for path, info in self.entries.items(): + if not info.get("dir"): + continue + if paths is None or any( + path == sel or path.startswith(sel.rstrip("/") + "/") + for sel in self._selectors + ): + os.makedirs(os.path.join(out_dir, *path.split("/")), exist_ok=True) + stats.directories += 1 + + # 对象列表:一个 blob 可能被多个路径引用,只下一次 + by_hash = {} + for path in sorted(wanted): + info = self.entries[path] + by_hash.setdefault(info["hash"], []).append(path) + + total = len(by_hash) + results = {} + + def work(content_hash): + return content_hash, self.get_object(content_hash) + + with ThreadPoolExecutor(max_workers=self.workers) as pool: + for content_hash, (source, from_cache, downloaded) in pool.map( + work, sorted(by_hash) + ): + results[content_hash] = source + stats.verified += 1 + if from_cache: + stats.objects_from_cache += 1 + else: + stats.objects_downloaded += 1 + stats.bytes_downloaded += downloaded + if self.progress is not None: + self._done += 1 + self.progress(self._done, total) + + for content_hash, owners in by_hash.items(): + source = results[content_hash] + for path in owners: + info = self.entries[path] + target = os.path.join(out_dir, *path.split("/")) + self._materialize(source, target, info.get("mode"), info.get("mtime")) + stats.files += 1 + + return stats diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..687454c --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,466 @@ +"""测试公共工具:路径引导、临时目录、目录树构造与比对、打包/解包/CLI 封装。 + +测试用标准库 unittest 编写,因此 `python -m unittest discover -s tests` 与 +`pytest tests/` 都能运行,不需要额外安装依赖。 +""" + +import contextlib +import io +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SRC_DIR = os.path.join(REPO_ROOT, "src") +SAMPLE_DB = os.path.join(REPO_ROOT, "appcation.svfs") + +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + + +# -------------------------------------------------------------------------- +# 环境与清理 +# -------------------------------------------------------------------------- + + +def force_rmtree(path): + """删除目录树,容忍超长路径。 + + P0-2 的缺陷会在导出时生成几百层 sub/sub/sub/... 的垃圾目录链, + 普通 shutil.rmtree 在 Windows 上会因为超过 MAX_PATH 而删不掉。 + """ + if not path or not os.path.exists(path): + return + if os.name == "nt": + shutil.rmtree("\\\\?\\" + os.path.abspath(path), ignore_errors=True) + shutil.rmtree(path, ignore_errors=True) + + +@contextlib.contextmanager +def temp_dir(prefix="svfs_test_"): + path = tempfile.mkdtemp(prefix=prefix) + try: + yield path + finally: + force_rmtree(path) + + +@contextlib.contextmanager +def quiet(): + """屏蔽被测代码的进度输出,让测试失败信息保持可读。""" + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + yield buf + + +class VFSTestCase(unittest.TestCase): + """每个用例一份独立的临时目录。""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="svfs_test_") + self.addCleanup(force_rmtree, self.tmp) + + def path(self, *parts): + return os.path.join(self.tmp, *parts) + + +# -------------------------------------------------------------------------- +# 目录树构造与比对 +# -------------------------------------------------------------------------- + + +def make_tree(base_dir, spec): + """按 spec 生成目录树。 + + spec 的键是相对路径(用 / 分隔),值为 None 表示目录,bytes 表示文件内容。 + """ + for rel_path, content in spec.items(): + abs_path = os.path.join(base_dir, *rel_path.split("/")) + if content is None: + os.makedirs(abs_path, exist_ok=True) + else: + parent = os.path.dirname(abs_path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(abs_path, "wb") as f: + f.write(content) + return base_dir + + +def write_file(path, data): + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "wb") as f: + f.write(data) + return path + + +def read_bytes(path): + with open(path, "rb") as f: + return f.read() + + +def md5_of(path): + import hashlib + + return hashlib.md5(read_bytes(path)).hexdigest() + + +def read_tree(base_dir): + """读取目录树,返回 {相对路径(/ 分隔): bytes 或 None(目录)}。""" + result = {} + for root, dirs, files in os.walk(base_dir): + for name in dirs: + abs_path = os.path.join(root, name) + result[os.path.relpath(abs_path, base_dir).replace("\\", "/")] = None + for name in files: + abs_path = os.path.join(root, name) + with open(abs_path, "rb") as f: + result[os.path.relpath(abs_path, base_dir).replace("\\", "/")] = f.read() + return result + + +def diff_trees(expected, actual, limit=15): + """返回人类可读的目录树差异摘要,完全一致时返回 "无差异"。""" + missing = sorted(set(expected) - set(actual)) + extra = sorted(set(actual) - set(expected)) + changed = sorted( + rel for rel in set(expected) & set(actual) if expected[rel] != actual[rel] + ) + + lines = [] + if missing: + lines.append(f"缺失 {len(missing)} 项,例如: {missing[:limit]}") + if extra: + lines.append(f"多出 {len(extra)} 项,例如: {extra[:limit]}") + if changed: + lines.append(f"内容不一致 {len(changed)} 项,例如: {changed[:limit]}") + return "\n".join(lines) if lines else "无差异" + + +def assert_tree_equal(case, expected, actual): + case.assertEqual("无差异", diff_trees(expected, actual)) + + +# -------------------------------------------------------------------------- +# 数据库直接探查(绕过被测的公开 API,用于独立断言) +# -------------------------------------------------------------------------- + + +def all_paths(vfs): + """返回当前快照里所有 path 的集合。""" + return { + row["path"] + for row in vfs.conn.execute( + "SELECT path FROM files WHERE snapshot_id = ?", (vfs._active_snapshot(),) + ) + } + + +def path_parents(vfs): + """返回 {path: parent_path}(当前快照)。""" + return { + row["path"]: row["parent_path"] + for row in vfs.conn.execute( + "SELECT path, parent_path FROM files WHERE snapshot_id = ?", + (vfs._active_snapshot(),), + ) + } + + +def stored_files(vfs): + """返回 {path: content},仅文件(内容来自 blobs 表)。""" + return { + row["path"]: row["content"] + for row in vfs.conn.execute( + """ + SELECT f.path AS path, b.content AS content + FROM files f JOIN blobs b ON b.hash = f.content_hash + WHERE f.is_directory = 0 AND f.snapshot_id = ? + """, + (vfs._active_snapshot(),), + ) + } + + +def blob_count(vfs): + """库里 blob 的行数(去重后的内容份数)。""" + return vfs.conn.execute("SELECT COUNT(*) AS c FROM blobs").fetchone()["c"] + + +def stored_blobs(vfs): + """返回 {hash: (size, stored_size, compress_algo)}。""" + return { + row["hash"]: (row["size"], row["stored_size"], row["compress_algo"]) + for row in vfs.conn.execute( + "SELECT hash, size, stored_size, compress_algo FROM blobs" + ) + } + + +def column_names(conn, table="files"): + return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")} + + +def count_files(vfs): + return vfs.conn.execute( + "SELECT COUNT(*) AS c FROM files WHERE is_directory = 0 AND snapshot_id = ?", + (vfs._active_snapshot(),), + ).fetchone()["c"] + + +def seed_rows(vfs, rows): + """用原始 SQL 造数据(走 blobs + content_hash,等价于库内真实布局)。 + + 只写新旧 schema 共有的列,这样"目录语义"类测试不会被 + 存储层的细节连带打挂,便于定位问题。 + """ + import hashlib + + cursor = vfs.conn.cursor() + snapshot_id = vfs._ensure_snapshot() + for row in rows: + rel_path = row["path"] + if "/" in rel_path: + default_name = rel_path.rsplit("/", 1)[1] + default_parent = rel_path.rsplit("/", 1)[0] + else: + default_name = rel_path + default_parent = "" + is_directory = bool(row.get("is_directory")) + content = None if is_directory else (row.get("content") or b"") + content_hash = None + if content is not None: + content_hash = hashlib.sha256(content).hexdigest() + cursor.execute( + """ + INSERT OR IGNORE INTO blobs + (hash, size, stored_size, content, freq_blob, compress_algo) + VALUES (?, ?, ?, ?, NULL, 'none') + """, + (content_hash, len(content), len(content), sqlite3.Binary(content)), + ) + cursor.execute( + """ + INSERT INTO files + (snapshot_id, path, name, parent_path, is_directory, file_size, content_hash) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + snapshot_id, + rel_path, + row.get("name", default_name), + row.get("parent_path", default_parent), + 1 if is_directory else 0, + row.get("file_size", len(content) if content is not None else 0), + content_hash, + ), + ) + vfs.conn.commit() + # 摘要与统计要一起写上:seed_rows 造出来的库应当等价于"正常打包出来的库", + # 否则 verify() 会因为"没有记录摘要"而判失败(这是有意的保守策略) + vfs.recount() + + +def make_old_schema_db(db_path, extra_rows=()): + """生成 **v1** 旧格式数据库(content BLOB + compressed BOOLEAN)。 + + 对应仓库里 appcation.svfs 的结构,也用于验证迁移工具能处理最早的格式。 + """ + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE filesystem_metadata ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + total_files INTEGER DEFAULT 0, + total_size INTEGER DEFAULT 0 + ); + CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + parent_path TEXT, + is_directory BOOLEAN NOT NULL DEFAULT 0, + file_size INTEGER DEFAULT 0, + created_time DATETIME, + modified_time DATETIME, + permissions INTEGER DEFAULT 0644, + content BLOB, + compressed BOOLEAN DEFAULT 0 + ); + INSERT INTO filesystem_metadata (name, total_files, total_size) + VALUES ('legacy', 1, 5); + INSERT INTO files (path, name, parent_path, is_directory, file_size, content) + VALUES ('legacy.txt', 'legacy.txt', '', 0, 5, X'68656C6C6F'); + """ + ) + for row in extra_rows: + conn.execute( + """ + INSERT INTO files + (path, name, parent_path, is_directory, file_size, content, compressed) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + row["path"], + row["path"].rsplit("/", 1)[-1], + row.get("parent_path", ""), + 1 if row.get("is_directory") else 0, + row.get("file_size", len(row.get("content") or b"")), + sqlite3.Binary(row["content"]) if row.get("content") is not None else None, + 1 if row.get("compressed") else 0, + ), + ) + conn.commit() + finally: + conn.close() + return db_path + + +def make_v2_schema_db(db_path, rows=()): + """生成 **v2** 格式数据库:内容与压缩算法列都在 files 表里(无 blobs/snapshots)。 + + rows 里可以给 content / compress_algo / freq_blob 来模拟"已经压缩过的内容"。 + """ + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE filesystem_metadata ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + total_files INTEGER DEFAULT 0, + total_size INTEGER DEFAULT 0 + ); + CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + parent_path TEXT, + is_directory BOOLEAN NOT NULL DEFAULT 0, + file_size INTEGER DEFAULT 0, + created_time DATETIME, + modified_time DATETIME, + permissions INTEGER DEFAULT 420, + content BLOB, + freq_blob BLOB, + compress_algo TEXT DEFAULT 'none' + ); + INSERT INTO filesystem_metadata (name, total_files, total_size) + VALUES ('v2pack', 0, 0); + """ + ) + for row in rows: + conn.execute( + """ + INSERT INTO files + (path, name, parent_path, is_directory, file_size, + content, freq_blob, compress_algo) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row["path"], + row["path"].rsplit("/", 1)[-1], + row.get("parent_path", "" if "/" not in row["path"] else row["path"].rsplit("/", 1)[0]), + 1 if row.get("is_directory") else 0, + row.get("file_size", len(row.get("content") or b"")), + sqlite3.Binary(row["content"]) if row.get("content") is not None else None, + sqlite3.Binary(row["freq_blob"]) if row.get("freq_blob") else None, + row.get("compress_algo", "none"), + ), + ) + conn.commit() + finally: + conn.close() + return db_path + + +def vfs_format_error(): + """返回(修复后应当存在的)数据库格式异常类型;未实现时返回 None。""" + from sqlite_vfs import core + + return getattr(core, "VFSFormatError", None) + + +# -------------------------------------------------------------------------- +# 打包 / 解包 / CLI 封装 +# -------------------------------------------------------------------------- + + +def pack( + source_dir, + db_path, + compress=False, + exclude=None, + name="test_fs", + algorithm=None, + quiet_output=True, +): + from sqlite_vfs import compression + from sqlite_vfs.core import SQLiteVFS + from sqlite_vfs.folder_packer import FolderPacker + + kwargs = {} + if algorithm is not None: + kwargs["compress_algorithm"] = algorithm + vfs = SQLiteVFS(db_path, compress=compress, **kwargs) + try: + packer = FolderPacker( + sqlite_vfs=vfs, exclude_patterns=exclude or [], fs_name=name + ) + if quiet_output: + with quiet(): + packer.pack_folder(source_dir) + else: + packer.pack_folder(source_dir) + finally: + vfs.close() + return db_path + + +def unpack(db_path, out_dir, ignore_timestamps=False): + from sqlite_vfs.core import SQLiteVFS + from sqlite_vfs.folder_unpacker import FolderUnpacker + + vfs = SQLiteVFS(db_path) + try: + unpacker = FolderUnpacker(vfs) + try: + return unpacker.unpack_filesystem( + out_dir, ignore_timestamps=ignore_timestamps + ) + except TypeError: + # 兼容尚未支持该参数的老接口 + return unpacker.unpack_filesystem(out_dir) + finally: + vfs.close() + + +def run_cli(module, *args): + """以子进程运行 CLI 模块,返回 CompletedProcess(text 模式)。""" + env = dict(os.environ) + env["PYTHONPATH"] = SRC_DIR + os.pathsep + env.get("PYTHONPATH", "") + env["PYTHONIOENCODING"] = "utf-8" + return subprocess.run( + [sys.executable, "-m", module, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=REPO_ROOT, + ) + + +def cli_output(result): + """合并 stdout/stderr,便于断言错误信息。""" + return (result.stdout or "") + (result.stderr or "") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c63b5ff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,443 @@ +"""CLI 行为测试(以子进程方式运行,覆盖退出码与各选项语义)。""" + +import json +import os +import sqlite3 +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS + +PACKER = "sqlite_vfs.cli.packer" +UNPACKER = "sqlite_vfs.cli.unpacker" +VERIFY = "sqlite_vfs.cli.verify" +MIGRATE = "sqlite_vfs.cli.migrate" + + +SMALL_SPEC = { + "top.txt": b"top\n", + "sub/inner.txt": b"inner\n", + "sub/deep/leaf.bin": b"leaf\n", + "empty_dir": None, +} + + +class CliFixture(helpers.VFSTestCase): + def seed_db(self, name="seeded.svfs"): + """用库接口直接造一个库,让 CLI 测试不被打包链路的缺陷连带影响。""" + db = self.path(name) + vfs = SQLiteVFS(db) + try: + helpers.seed_rows( + vfs, + [ + {"path": "doc", "is_directory": True}, + {"path": "doc/a.txt", "content": b"a"}, + {"path": "doc/sub", "is_directory": True}, + {"path": "doc/sub/b.txt", "content": b"b"}, + {"path": "docs", "is_directory": True}, + {"path": "top.txt", "content": b"t"}, + ], + ) + finally: + vfs.close() + return db + + +class CliFailureTest(CliFixture): + """P1-8:失败路径必须返回非 0 退出码,且给出干净的错误信息而不是 traceback。""" + + def test_pack_missing_source_returns_nonzero(self): + result = helpers.run_cli(PACKER, self.path("nope"), self.path("out.svfs")) + self.assertNotEqual(0, result.returncode, "源目录不存在却返回了成功") + self.assertNotIn("Traceback", helpers.cli_output(result)) + + def test_unpack_missing_db_returns_nonzero(self): + result = helpers.run_cli(UNPACKER, self.path("nope.svfs"), self.path("out")) + self.assertNotEqual(0, result.returncode, "数据库不存在却返回了成功") + self.assertNotIn("Traceback", helpers.cli_output(result)) + + def test_pack_to_missing_output_directory_reports_clean_error(self): + src = helpers.make_tree(self.path("src"), {"a.txt": b"a"}) + result = helpers.run_cli(PACKER, src, self.path("missing_dir", "out.svfs")) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + self.assertIn("错误", helpers.cli_output(result)) + + def test_unpack_non_database_file_reports_clean_error(self): + junk = helpers.write_file(self.path("junk.svfs"), b"definitely not sqlite") + result = helpers.run_cli(UNPACKER, junk, self.path("out")) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + + def test_unpack_missing_path_returns_nonzero(self): + db = self.seed_db() + result = helpers.run_cli(UNPACKER, db, self.path("out"), "--path", "nope") + self.assertNotEqual(0, result.returncode, "导出不存在的路径却返回了成功") + + +class CliPackTest(CliFixture): + def test_pack_stores_every_file(self): + src = helpers.make_tree(self.path("src"), SMALL_SPEC) + db = self.path("packed.svfs") + result = helpers.run_cli(PACKER, src, db, "--compress", "--name", "demo") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + expected = {rel for rel, content in SMALL_SPEC.items() if content is not None} + self.assertEqual(expected, set(helpers.stored_files(vfs))) + self.assertEqual("demo", vfs.get_stats()["name"]) + + def test_exclude_supports_glob_patterns(self): + """P1-9: README 承诺 glob 语义,`*.log` 必须真的排除 .log 文件, + 同时不能把 catalog.txt 这类"看起来像"的文件误伤。""" + spec = { + "keep.txt": b"k", + "skip.log": b"s", + "catalog.txt": b"c", + "sub/skip.tmp": b"t", + "sub/keep2.txt": b"k2", + } + src = helpers.make_tree(self.path("src"), spec) + db = self.path("packed.svfs") + result = helpers.run_cli(PACKER, src, db, "--exclude", "*.log", "*.tmp") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual( + {"keep.txt", "catalog.txt", "sub/keep2.txt"}, set(helpers.stored_files(vfs)) + ) + + def test_exclude_prunes_directory_subtree(self): + spec = { + "keep.txt": b"k", + "vendor/lib.txt": b"v", + "vendor/deep/more.txt": b"m", + } + src = helpers.make_tree(self.path("src"), spec) + db = self.path("packed.svfs") + result = helpers.run_cli(PACKER, src, db, "--exclude", "vendor") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual({"keep.txt"}, set(helpers.stored_files(vfs))) + self.assertNotIn("vendor", helpers.all_paths(vfs)) + + def test_pack_reports_file_count_matching_database(self): + src = helpers.make_tree(self.path("src"), SMALL_SPEC) + db = self.path("packed.svfs") + result = helpers.run_cli(PACKER, src, db) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + reported = vfs.get_stats()["total_files"] + expected = len([1 for content in SMALL_SPEC.values() if content is not None]) + self.assertEqual(expected, reported) + + def test_default_compression_algorithm_is_zlib(self): + spec = { + "app.js": b"console.log('hello');\n" * 40, # ~920 字节,高于阈值 + "tiny.txt": b"hi", # 低于阈值,应当原样存储 + } + src = helpers.make_tree(self.path("src"), spec) + db = self.path("packed.svfs") + result = helpers.run_cli(PACKER, src, db, "--compress") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + algorithms = { + row["path"]: row["compress_algo"] + for row in vfs.conn.execute( + """ + SELECT f.path AS path, b.compress_algo AS compress_algo + FROM files f JOIN blobs b ON b.hash = f.content_hash + WHERE f.is_directory = 0 + """ + ) + } + self.assertEqual("zlib", algorithms["app.js"]) + self.assertEqual("none", algorithms["tiny.txt"]) + self.assertEqual(spec["app.js"], vfs.read_file("app.js")) + + def test_compress_algo_huffman_is_still_selectable(self): + """早期算法仍然可选,且打出来的包能被正常读回。""" + src = helpers.make_tree(self.path("src"), SMALL_SPEC) + db = self.path("packed.svfs") + result = helpers.run_cli( + PACKER, src, db, "--compress", "--compress-algo", "huffman" + ) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(b"top\n", vfs.read_file("top.txt")) + + out = self.path("out") + unpack_result = helpers.run_cli(UNPACKER, db, out) + self.assertEqual(0, unpack_result.returncode, helpers.cli_output(unpack_result)) + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) + + +class CliUnpackTest(CliFixture): + def test_full_roundtrip_via_cli(self): + src = helpers.make_tree(self.path("src"), SMALL_SPEC) + db = self.path("packed.svfs") + out = self.path("out") + + pack_result = helpers.run_cli(PACKER, src, db, "--compress") + self.assertEqual(0, pack_result.returncode, helpers.cli_output(pack_result)) + + unpack_result = helpers.run_cli(UNPACKER, db, out) + self.assertEqual(0, unpack_result.returncode, helpers.cli_output(unpack_result)) + + # 以磁盘上的源目录为基准(make_tree 会补出 sub、sub/deep 这些中间目录) + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) + + def test_list_root_only_shows_top_level(self): + """P0-2: 根目录列表不能把整张表铺平。""" + db = self.seed_db() + result = helpers.run_cli(UNPACKER, db, self.path("out"), "--list-root") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + lines = [ + line + for line in result.stdout.splitlines() + if line.strip().startswith(("DIR:", "FILE:")) + ] + self.assertEqual(3, len(lines), f"根目录应当只有 3 个条目,实际输出:\n{result.stdout}") + for line in lines: + listed = line.split("路径: ")[-1] + self.assertNotIn("/", listed, "根目录列表里出现了子目录条目") + + def test_path_exports_subdirectory_with_contents(self): + db = self.seed_db() + out = self.path("out") + result = helpers.run_cli(UNPACKER, db, out, "--path", "doc") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + expected = {"a.txt": b"a", "sub": None, "sub/b.txt": b"b"} + helpers.assert_tree_equal(self, expected, helpers.read_tree(os.path.join(out, "doc"))) + + def test_path_exports_single_file(self): + db = self.seed_db() + out = self.path("out") + result = helpers.run_cli(UNPACKER, db, out, "--path", "top.txt") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + with open(os.path.join(out, "top.txt"), "rb") as f: + self.assertEqual(b"t", f.read()) + + def test_ignore_timestamps_full_export_does_not_recurse(self): + """P0-3: --ignore-timestamps 曾把 export_filesystem 赋成引用自身的 lambda。""" + db = self.seed_db() + out = self.path("out") + result = helpers.run_cli(UNPACKER, db, out, "--ignore-timestamps") + output = helpers.cli_output(result) + + self.assertNotIn("RecursionError", output) + self.assertEqual(0, result.returncode, output) + + expected = { + "doc": None, + "doc/a.txt": b"a", + "doc/sub": None, + "doc/sub/b.txt": b"b", + "docs": None, + "top.txt": b"t", + } + helpers.assert_tree_equal(self, expected, helpers.read_tree(out)) + + def test_ignore_timestamps_with_path_subdirectory(self): + db = self.seed_db() + out = self.path("out") + result = helpers.run_cli( + UNPACKER, db, out, "--path", "doc", "--ignore-timestamps" + ) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + expected = {"a.txt": b"a", "sub": None, "sub/b.txt": b"b"} + helpers.assert_tree_equal(self, expected, helpers.read_tree(os.path.join(out, "doc"))) + + def test_rejects_old_schema_database_cleanly(self): + """P1-7: 旧格式库要给出明确提示,退出码非 0,且不能是 traceback。""" + db = helpers.make_old_schema_db(self.path("legacy.svfs")) + result = helpers.run_cli(UNPACKER, db, self.path("out")) + output = helpers.cli_output(result) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", output) + + +class CliVerifyTest(CliFixture): + def test_verify_reports_ok(self): + db = self.seed_db() + result = helpers.run_cli(VERIFY, db) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("完整性校验通过", result.stdout) + self.assertIn("未做内容哈希校验", result.stdout) + + def test_verify_with_content_check(self): + db = self.seed_db() + result = helpers.run_cli(VERIFY, db, "--check-content") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("做了内容哈希校验", result.stdout) + + def test_verify_exits_nonzero_on_metadata_tampering(self): + db = self.seed_db() + conn = sqlite3.connect(db) + # 夹具里的文件都是 1 字节,所以要用一个明显不同的值来制造篡改 + conn.execute("UPDATE files SET file_size = 999 WHERE is_directory = 0") + conn.commit() + conn.close() + + result = helpers.run_cli(VERIFY, db) + self.assertNotEqual(0, result.returncode, "校验失败必须返回非 0") + self.assertIn("校验失败", result.stdout) + self.assertIn("摘要不一致", result.stdout) + + def test_content_tampering_needs_check_content(self): + db = self.seed_db() + conn = sqlite3.connect(db) + conn.execute("UPDATE blobs SET content = X'00'") + conn.commit() + conn.close() + self.assertEqual(0, helpers.run_cli(VERIFY, db).returncode, "元数据级校验看不出来") + self.assertNotEqual( + 0, helpers.run_cli(VERIFY, db, "--check-content").returncode + ) + + def test_list_snapshots(self): + db = self.seed_db() + result = helpers.run_cli(VERIFY, db, "--list-snapshots") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("*", result.stdout, "默认快照应当被标出来") + + def test_sign_then_verify(self): + db = self.seed_db() + signed = helpers.run_cli(VERIFY, db, "--sign", "--key", "sec", "--key-id", "k1") + self.assertEqual(0, signed.returncode, helpers.cli_output(signed)) + self.assertIn("已签名", signed.stdout) + + passed = helpers.run_cli(VERIFY, db, "--key", "sec", "--key-id", "k1") + self.assertEqual(0, passed.returncode, helpers.cli_output(passed)) + self.assertIn("签名: OK", passed.stdout) + + failed = helpers.run_cli(VERIFY, db, "--key", "wrong") + self.assertNotEqual(0, failed.returncode) + self.assertIn("签名: 失败", failed.stdout) + + def test_json_output(self): + db = self.seed_db() + result = helpers.run_cli(VERIFY, db, "--json") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + payload = json.loads(result.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(64, len(payload["actual_digest"])) + + def test_snapshot_selection(self): + db = self.seed_db() + result = helpers.run_cli(VERIFY, db, "--snapshot", "1") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("快照: 1", result.stdout) + + def test_missing_db_exits_nonzero(self): + result = helpers.run_cli(VERIFY, self.path("nope.svfs")) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + + def test_verify_does_not_modify_the_pack(self): + db = self.seed_db() + before = helpers.md5_of(db) + helpers.run_cli(VERIFY, db, "--check-content") + self.assertEqual(before, helpers.md5_of(db), "校验不该改动资源包") + + +class CliUnpackSnapshotTest(CliFixture): + def test_list_snapshots_option(self): + db = self.seed_db() + result = helpers.run_cli(UNPACKER, db, self.path("out"), "--list-snapshots") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("ID", result.stdout) + + def test_unknown_snapshot_is_rejected_cleanly(self): + db = self.seed_db() + result = helpers.run_cli(UNPACKER, db, self.path("out"), "--snapshot", "999") + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + + +class CliMigrateTest(helpers.VFSTestCase): + def _v2_db(self, name="v2.svfs"): + return helpers.make_v2_schema_db( + self.path(name), + rows=[ + {"path": "a.txt", "content": b"aaa"}, + {"path": "b.txt", "content": b"bbb"}, + ], + ) + + def test_migrates_in_place_with_backup(self): + db = self._v2_db() + result = helpers.run_cli(MIGRATE, db) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("迁移完成", result.stdout) + self.assertIn("备份保留", result.stdout) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(b"aaa", vfs.read_file("a.txt")) + self.assertTrue(vfs.verify()["ok"]) + + def test_reports_when_nothing_to_do(self): + db = self.path("v3.svfs") + SQLiteVFS(db).close() + result = helpers.run_cli(MIGRATE, db) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertIn("无需迁移", result.stdout) + + def test_output_mode_leaves_the_original_alone(self): + db = self._v2_db() + before = helpers.md5_of(db) + target = self.path("new.svfs") + result = helpers.run_cli(MIGRATE, db, "--output", target) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertEqual(before, helpers.md5_of(db)) + self.assertTrue(os.path.exists(target)) + + def test_non_vfs_file_is_rejected(self): + junk = helpers.write_file(self.path("junk.svfs"), b"not a database") + result = helpers.run_cli(MIGRATE, junk) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + + +class DemoScriptTest(helpers.VFSTestCase): + """main.py 是给新人的冒烟示例——没人跑它就会烂(上一版引用的路径早已不存在)。""" + + def test_demo_runs_and_reports_success(self): + import subprocess + import sys + + env = dict(os.environ) + env["PYTHONPATH"] = helpers.SRC_DIR + os.pathsep + env.get("PYTHONPATH", "") + env["PYTHONIOENCODING"] = "utf-8" + result = subprocess.run( + [sys.executable, os.path.join(helpers.REPO_ROOT, "main.py")], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + cwd=helpers.REPO_ROOT, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("一致", result.stdout) + self.assertNotIn("不一致", result.stdout) + self.assertNotIn("Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compression.py b/tests/test_compression.py new file mode 100644 index 0000000..7f24cfc --- /dev/null +++ b/tests/test_compression.py @@ -0,0 +1,112 @@ +"""压缩算法分发层的直接单测。 + +`compression.py` 是"用哪种算法"的唯一分发点(core 只按 `compress_algo` 值分发, +不关心具体算法)。这里锁住它的契约,这样以后往 `ALGORITHMS` 里加算法 +(包括自定义算法、或未来的加密层)时,有东西能兜住回归。 + +分层关系:`huffman.py` 只管纯算法(有自己的 test_huffman.py), +`compression.py` 管分发与算法名到数据的映射,`core.py` 管存取。 +""" + +import unittest +import zlib + +import helpers # noqa: F401 -- 把 src 加入 sys.path + +from sqlite_vfs import compression +from sqlite_vfs.huffman import FREQ_SIZE + + +class AlgorithmRegistryTest(unittest.TestCase): + def test_default_algorithm_is_zlib(self): + self.assertEqual("zlib", compression.DEFAULT_ALGORITHM) + + def test_registry_contains_the_three_builtins(self): + self.assertEqual(("none", "zlib", "huffman"), compression.ALGORITHMS) + + def test_every_registered_algorithm_roundtrips(self): + data = b"the quick brown fox jumps over the lazy dog. " * 60 + for algorithm in compression.ALGORITHMS: + with self.subTest(algorithm=algorithm): + algo, freq, blob = compression.compress_with(data, algorithm) + self.assertEqual(algorithm, algo) + self.assertEqual(data, compression.decompress_with(algo, freq, blob)) + + def test_unknown_algorithm_is_rejected_on_both_sides(self): + with self.assertRaises(ValueError) as ctx: + compression.compress_with(b"x", "lzma") + self.assertIn("lzma", str(ctx.exception)) + self.assertIn("none", str(ctx.exception), "报错要列出可用算法") + + with self.assertRaises(ValueError): + compression.decompress_with("lzma", None, b"x") + + +class CompressContractTest(unittest.TestCase): + DATA = b"payload " * 100 + + def test_none_returns_the_input_unchanged(self): + algo, freq, blob = compression.compress_with(self.DATA, "none") + self.assertEqual("none", algo) + self.assertIsNone(freq) + self.assertIs(self.DATA, blob, "none 应当零拷贝直接返回原对象") + + def test_zlib_produces_a_real_zlib_stream(self): + algo, freq, blob = compression.compress_with(self.DATA, "zlib") + self.assertIsNone(freq, "zlib 不需要附加数据") + self.assertEqual(self.DATA, zlib.decompress(blob)) + self.assertLess(len(blob), len(self.DATA)) + + def test_huffman_carries_a_fixed_size_frequency_table(self): + algo, freq, blob = compression.compress_with(self.DATA, "huffman") + self.assertEqual("huffman", algo) + self.assertEqual(FREQ_SIZE, len(freq), "频率表固定 1024 字节") + self.assertEqual(self.DATA, compression.decompress_with(algo, freq, blob)) + + def test_huffman_without_frequency_table_is_rejected(self): + algo, _freq, blob = compression.compress_with(self.DATA, "huffman") + with self.assertRaises(ValueError) as ctx: + compression.decompress_with(algo, None, blob) + self.assertIn("频率表", str(ctx.exception)) + + def test_stored_size_counts_the_frequency_table(self): + _algo, freq, blob = compression.compress_with(self.DATA, "huffman") + self.assertEqual( + len(blob) + len(freq), compression.stored_size(blob, freq) + ) + self.assertEqual(len(blob), compression.stored_size(blob, None)) + + def test_stored_size_ignores_an_empty_frequency_table(self): + self.assertEqual(3, compression.stored_size(b"abc", b"")) + + +class AlgorithmTradeoffTest(unittest.TestCase): + """把实测出来的取舍钉在测试里:默认算法不能悄悄退化成更差的那个。""" + + #: 高度重复的文本——LZ77 的强项,纯熵编码的弱项 + REPETITIVE = b"function render(el){return el.innerHTML}\n" * 800 + + def test_zlib_beats_huffman_by_a_lot_on_repetitive_text(self): + _a1, f1, z = compression.compress_with(self.REPETITIVE, "zlib") + _a2, f2, h = compression.compress_with(self.REPETITIVE, "huffman") + zlib_total = compression.stored_size(z, f1) + huffman_total = compression.stored_size(h, f2) + self.assertLess( + zlib_total * 10, + huffman_total, + f"zlib={zlib_total} huffman={huffman_total}——差距应当在一个数量级上", + ) + + def test_huffman_is_not_used_for_small_files(self): + """huffman 每个文件要带 1024 字节频率表,小文件必然负收益。""" + small = b"tiny content" + _algo, freq, blob = compression.compress_with(small, "huffman") + self.assertGreater( + compression.stored_size(blob, freq), + len(small), + "小文件压完更大——这正是 core 用 COMPRESS_MIN_SIZE 挡住它的原因", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contrib_optional.py b/tests/test_contrib_optional.py new file mode 100644 index 0000000..f82e334 --- /dev/null +++ b/tests/test_contrib_optional.py @@ -0,0 +1,101 @@ +"""验证"插件是可选依赖"这一契约。 + +这个模块在**有/没有** FastAPI 的两种环境下都会运行: +它断言的是"核心包永远不依赖第三方框架",以及"缺依赖时的报错要能直接照做"。 +""" + +import os +import subprocess +import sys +import unittest + +import helpers + +THIRD_PARTY = ("fastapi", "starlette", "anyio", "pydantic") + + +class OptionalDependencyTest(unittest.TestCase): + def run_snippet(self, code): + env = dict(os.environ) + env["PYTHONPATH"] = helpers.SRC_DIR + os.pathsep + env.get("PYTHONPATH", "") + env["PYTHONIOENCODING"] = "utf-8" + return subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=env, + ) + + def test_third_party_is_installed_or_not(self): + """只用于在输出里说明当前环境状态,不断言。""" + result = self.run_snippet( + "import importlib.util as u;" + "print({m: bool(u.find_spec(m)) for m in %r})" % (THIRD_PARTY,) + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_importing_core_never_pulls_in_third_party(self): + """import sqlite_vfs 不能顺带把 fastapi/starlette 拖进来。""" + result = self.run_snippet( + "import sys\n" + "import sqlite_vfs, sqlite_vfs.core, sqlite_vfs.contrib\n" + "leaked = sorted(\n" + " m for m in sys.modules if m.split('.')[0] in %r\n" + ")\n" + "print(leaked)" % (THIRD_PARTY,) + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual( + "[]", + result.stdout.strip(), + "导入 sqlite_vfs 时连带加载了第三方框架模块", + ) + + def test_plugin_module_is_not_imported_by_the_package(self): + """contrib 子包不能自动导入插件模块(否则缺依赖时整包不可用)。""" + result = self.run_snippet( + "import sys\n" + "import sqlite_vfs.contrib\n" + "print('sqlite_vfs.contrib.fastapi_static' in sys.modules)" + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("False", result.stdout.strip()) + + def test_missing_fastapi_gives_actionable_error(self): + """缺少可选依赖时,报错信息必须能直接照着做。""" + probe = self.run_snippet( + "import importlib.util as u; print(bool(u.find_spec('starlette')))" + ) + if probe.stdout.strip() == "True": + self.skipTest("当前环境已安装 starlette,无需检查缺失提示") + + result = self.run_snippet("import sqlite_vfs.contrib.fastapi_static") + self.assertNotEqual(0, result.returncode) + self.assertIn("sqlite-vfs[fastapi]", result.stderr) + self.assertIn("pip install", result.stderr) + + def test_missing_database_path_hint(self): + """未生成资源包时的报错要给出下一步命令。""" + result = self.run_snippet( + "import tempfile, os, sys\n" + "sys.path.insert(0, %r)\n" + "try:\n" + " from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles\n" + "except ImportError as e:\n" + " print('SKIP', e); raise SystemExit(0)\n" + "try:\n" + " SVFSStaticFiles(os.path.join(tempfile.mkdtemp(), 'nope.svfs'))\n" + "except RuntimeError as e:\n" + " print(e)\n" % helpers.SRC_DIR + ) + self.assertEqual(0, result.returncode, result.stderr) + output = result.stdout.strip() + if output.startswith("SKIP"): + self.skipTest("当前环境没有 FastAPI") + self.assertIn("vfspack", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..d75e207 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,993 @@ +"""SQLiteVFS 核心行为的契约测试。 + +用例按缺陷编号分组,命名对应 BUG_REPORT.md 里的条目,便于"哪条修好了"一目了然。 +""" + +import os +import re +import sqlite3 +import unittest +import warnings + +import helpers + +from sqlite_vfs.core import SQLiteVFS, VFSIntegrityError + + +class SchemaContractTest(helpers.VFSTestCase): + """P0-1:建表列名必须与 INSERT / read_file 使用的列名一致。""" + + def test_expected_columns_exist(self): + """v3:目录项与内容分家,files 只留元数据 + content_hash。""" + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + + file_columns = helpers.column_names(vfs.conn, "files") + for name in ( + "snapshot_id", + "path", + "name", + "parent_path", + "is_directory", + "file_size", + "modified_time", + "permissions", + "content_hash", + ): + self.assertIn(name, file_columns, f"files 表缺少列 {name}") + for name in ("content", "freq_blob", "compress_algo"): + self.assertNotIn( + name, file_columns, f"{name} 应当已经搬到 blobs 表" + ) + + blob_columns = helpers.column_names(vfs.conn, "blobs") + for name in ("hash", "size", "stored_size", "content", "freq_blob", "compress_algo"): + self.assertIn(name, blob_columns, f"blobs 表缺少列 {name}") + + snapshot_columns = helpers.column_names(vfs.conn, "snapshots") + for name in ("id", "name", "parent_id", "total_files", "total_size", "digest"): + self.assertIn(name, snapshot_columns, f"snapshots 表缺少列 {name}") + + def test_add_file_actually_stores_a_row(self): + """add_file 之后库里必须真的有这条记录。 + + 缺陷版本里 INSERT 的列名(compress_algo)与建表列名(compresse_algo) + 不一致,异常被 except 吞掉,于是打包"成功"但库里一个文件都没有。 + """ + src = helpers.write_file(self.path("input.bin"), b"payload-bytes") + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + + with helpers.quiet(): + vfs.add_file(src, "input.bin") + + self.assertTrue(vfs.exists("input.bin"), "add_file 没有把文件写进数据库") + self.assertEqual(b"payload-bytes", vfs.read_file("input.bin")) + + +class PersistenceTest(helpers.VFSTestCase): + """P0-4:add_file 必须自己提交事务,不能依赖后续调用代为 commit。""" + + def test_add_file_survives_close_without_further_commit(self): + db = self.path("commit.svfs") + src = helpers.write_file(self.path("x.bin"), b"payload") + + vfs = SQLiteVFS(db) + with helpers.quiet(): + vfs.add_file(src, "x.bin") + vfs.close() # 故意不再调用任何会 commit 的方法 + + reopened = SQLiteVFS(db) + self.addCleanup(reopened.close) + self.assertTrue( + reopened.exists("x.bin"), + "add_file 之后直接 close(),数据丢失——事务没有提交", + ) + + +class FileContentTest(helpers.VFSTestCase): + """P0-1 的下游:写入与读出的字节必须完全一致。""" + + def _write_and_read(self, data, rel_path="data.bin", compress=False): + src = helpers.write_file(self.path("input.bin"), data) + vfs = SQLiteVFS(":memory:", compress=compress) + self.addCleanup(vfs.close) + with helpers.quiet(): + vfs.add_file(src, rel_path) + return vfs, vfs.read_file(rel_path) + + def test_small_file_roundtrip(self): + vfs, content = self._write_and_read(b"hello sqlite vfs") + self.assertEqual(b"hello sqlite vfs", content) + self.assertEqual("none", vfs.get_blob_info("data.bin")["compress_algo"]) + + def test_empty_file_roundtrip(self): + _, content = self._write_and_read(b"") + self.assertEqual(b"", content) + + def test_compressible_file_uses_zlib(self): + data = b"repeat me please. " * 500 # 9000 字节,高于阈值 + vfs, content = self._write_and_read(data, compress=True) + self.assertEqual(data, content) + self.assertEqual( + "zlib", + vfs.get_blob_info("data.bin")["compress_algo"], + "默认算法应当是 zlib", + ) + self.assertIsNone( + vfs.get_blob_info("data.bin")["freq_blob"], + "zlib 不需要频率表,freq_blob 应当为 NULL", + ) + + def test_incompressible_file_falls_back_to_none(self): + import random + + data = random.Random(7).randbytes(4096) + vfs, content = self._write_and_read(data, compress=True) + self.assertEqual(data, content) + self.assertEqual( + "none", + vfs.get_blob_info("data.bin")["compress_algo"], + "随机数据压不动时应当保留原始字节", + ) + + def test_compression_applies_below_the_old_1kb_bar(self): + """zlib 包头只有几字节,小文件也值得压(旧的哈夫曼阈值是 1KB)。""" + data = b"body{margin:0}" * 30 # 450 字节 + vfs, content = self._write_and_read(data, compress=True) + self.assertEqual(data, content) + self.assertEqual("zlib", vfs.get_blob_info("data.bin")["compress_algo"]) + + def test_tiny_file_is_not_compressed(self): + data = b"hi" + vfs, content = self._write_and_read(data, compress=True) + self.assertEqual(data, content) + self.assertEqual( + "none", + vfs.get_blob_info("data.bin")["compress_algo"], + "低于 zlib 的最小压缩体积时应当原样存储", + ) + + def test_file_size_recorded_matches_content(self): + data = b"1234567890" + vfs, _ = self._write_and_read(data) + self.assertEqual(10, vfs.get_file_info("data.bin")["file_size"]) + + def test_read_file_offset_and_size(self): + vfs, _ = self._write_and_read(b"0123456789") + self.assertEqual(b"234", vfs.read_file("data.bin", 2, 3)) + self.assertEqual(b"56789", vfs.read_file("data.bin", 5)) + self.assertEqual(b"", vfs.read_file("data.bin", 10)) + + def test_read_file_rejects_directory_and_missing_path(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.seed_rows(vfs, [{"path": "d", "is_directory": True}]) + with self.assertRaises(IsADirectoryError): + vfs.read_file("d") + with self.assertRaises(FileNotFoundError): + vfs.read_file("nope.txt") + + +class CompressionAlgorithmTest(helpers.VFSTestCase): + """zlib 是默认算法;huffman 保留只有一个理由——读得懂早期版本打的包。""" + + DATA = b"function render(el){return el.innerHTML}\n" * 200 + + def _vfs_with(self, algorithm, compress=True): + src = helpers.write_file(self.path("input.bin"), self.DATA) + vfs = SQLiteVFS(":memory:", compress=compress, compress_algorithm=algorithm) + self.addCleanup(vfs.close) + with helpers.quiet(): + vfs.add_file(src, "data.bin") + return vfs + + def test_default_algorithm_is_zlib(self): + from sqlite_vfs import compression + + self.assertEqual("zlib", compression.DEFAULT_ALGORITHM) + vfs = self._vfs_with(compression.DEFAULT_ALGORITHM) + self.assertEqual("zlib", vfs.get_blob_info("data.bin")["compress_algo"]) + + def test_each_algorithm_roundtrips(self): + for algorithm in ("zlib", "huffman"): + with self.subTest(algorithm=algorithm): + vfs = self._vfs_with(algorithm) + self.assertEqual( + algorithm, vfs.get_blob_info("data.bin")["compress_algo"] + ) + self.assertEqual(self.DATA, vfs.read_file("data.bin")) + + def test_early_huffman_packs_are_still_readable(self): + """早期版本用 huffman 打的包必须还能读——这是保留该算法唯一的理由。""" + vfs = self._vfs_with("huffman") + blob = vfs.get_blob_info("data.bin") + self.assertIsNotNone(blob["freq_blob"], "huffman 必须带频率表") + self.assertEqual(self.DATA, vfs.read_file("data.bin")) + + def test_zlib_compresses_better_than_huffman(self): + """同一份文本,zlib 的结果必须明显小于 huffman(LZ77 能吃重复)。""" + stored = {} + for algorithm in ("zlib", "huffman"): + vfs = self._vfs_with(algorithm) + stored[algorithm] = vfs.get_blob_info("data.bin")["stored_size"] + self.assertLess( + stored["zlib"], + stored["huffman"], + f"zlib={stored['zlib']} huffman={stored['huffman']}", + ) + + def test_compression_disabled_stores_raw(self): + vfs = self._vfs_with("zlib", compress=False) + self.assertEqual("none", vfs.get_blob_info("data.bin")["compress_algo"]) + self.assertEqual(self.DATA, vfs.read_file("data.bin")) + + def test_unknown_algorithm_rejected_at_construction(self): + with self.assertRaises(ValueError) as ctx: + SQLiteVFS(":memory:", compress_algorithm="lzma") + self.assertIn("lzma", str(ctx.exception)) + + def test_unknown_algorithm_in_database_raises_on_read(self): + """库里的算法名本版本不认识时要明确报错,而不是返回坏数据。""" + vfs = self._vfs_with("zlib", compress=False) + vfs.conn.execute( + "UPDATE blobs SET compress_algo = 'lzma', content = ?", + (sqlite3.Binary(b"whatever"),), + ) + vfs.conn.commit() + with self.assertRaises(ValueError) as ctx: + vfs.read_file("data.bin") + self.assertIn("lzma", str(ctx.exception)) + self.assertIn("data.bin", str(ctx.exception)) + + +class DirectoryListingTest(helpers.VFSTestCase): + """P0-2:list_directory 必须按 parent_path 过滤。 + + 用原始 SQL 造数据,避免被 P0-1 连带打挂,保证失败原因单一。 + """ + + def _vfs(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, + [ + {"path": "doc", "is_directory": True}, + {"path": "docs", "is_directory": True}, + {"path": "doc/a.txt", "content": b"a"}, + {"path": "doc/sub", "is_directory": True}, + {"path": "doc/sub/b.txt", "content": b"b"}, + {"path": "docs/c.txt", "content": b"c"}, + {"path": "empty_dir", "is_directory": True}, + {"path": "top.txt", "content": b"t"}, + ], + ) + return vfs + + def test_root_lists_top_level_only(self): + vfs = self._vfs() + paths = sorted(row["path"] for row in vfs.list_directory("/")) + self.assertEqual(["doc", "docs", "empty_dir", "top.txt"], paths) + + def test_subdirectory_lists_its_children(self): + vfs = self._vfs() + paths = sorted(row["path"] for row in vfs.list_directory("doc")) + self.assertEqual(["doc/a.txt", "doc/sub"], paths) + + def test_nested_directory_lists_its_children(self): + vfs = self._vfs() + paths = sorted(row["path"] for row in vfs.list_directory("doc/sub")) + self.assertEqual(["doc/sub/b.txt"], paths) + + def test_directories_come_before_files(self): + vfs = self._vfs() + rows = vfs.list_directory("doc") + self.assertEqual([1, 0], [row["is_directory"] for row in rows]) + + def test_leaf_directory_is_empty(self): + vfs = self._vfs() + self.assertEqual([], vfs.list_directory("empty_dir")) + self.assertEqual([], vfs.list_directory("no/such/dir")) + + def test_paths_are_returned_as_dicts_with_datetime(self): + vfs = self._vfs() + rows = vfs.list_directory("doc") + self.assertIsInstance(rows[0], dict) + self.assertIn("created_time", rows[0]) + + +class MoveAndRemoveTest(helpers.VFSTestCase): + """P1-1 / P1-2 / P1-3:前缀误伤、重复改名、根目录整体移动。""" + + def _seed(self, rows=None): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, + rows + or [ + {"path": "doc", "is_directory": True}, + {"path": "doc/a.txt", "content": b"a"}, + {"path": "doc/sub", "is_directory": True}, + {"path": "doc/sub/b.txt", "content": b"b"}, + {"path": "docs", "is_directory": True}, + {"path": "docs/c.txt", "content": b"c"}, + {"path": "top.txt", "content": b"t"}, + ], + ) + return vfs + + def test_remove_directory_does_not_touch_prefix_sibling(self): + """删除 doc 不能连带删掉 docs(LIKE 'doc%' 会把 docs 一起匹配上)。""" + vfs = self._seed() + vfs.remove("doc") + + paths = helpers.all_paths(vfs) + self.assertNotIn("doc", paths) + self.assertEqual( + {"docs", "docs/c.txt", "top.txt"}, + paths, + "删除 doc 误伤了 docs", + ) + + def test_remove_file_with_underscore_is_not_a_wildcard(self): + """文件名里的 _ 不能被当成 LIKE 的单字符通配符。""" + vfs = self._seed( + [ + {"path": "a_b.txt", "content": b"1"}, + {"path": "axb.txt", "content": b"2"}, + ] + ) + vfs.remove("a_b.txt") + self.assertEqual({"axb.txt"}, helpers.all_paths(vfs)) + + def test_remove_file_with_percent_is_not_a_wildcard(self): + vfs = self._seed( + [ + {"path": "100%done.txt", "content": b"1"}, + {"path": "100Xdoney.txt", "content": b"2"}, + ] + ) + vfs.remove("100%done.txt") + self.assertEqual({"100Xdoney.txt"}, helpers.all_paths(vfs)) + + def test_remove_missing_path_reports_error(self): + vfs = self._seed() + with self.assertRaises(FileNotFoundError): + vfs.remove("no/such/thing.txt") + + def test_mv_dir_rename_is_not_applied_twice(self): + """P1-2: mv_dir("doc","doc_new") 的第二条 UPDATE 会再次命中已改名的目录自身。""" + vfs = self._seed() + vfs.mv_dir("doc", "doc_new") + + self.assertEqual( + { + "doc_new", + "doc_new/a.txt", + "doc_new/sub", + "doc_new/sub/b.txt", + "docs", + "docs/c.txt", + "top.txt", + }, + helpers.all_paths(vfs), + ) + + def test_mv_dir_does_not_touch_prefix_sibling(self): + vfs = self._seed() + vfs.mv_dir("doc", "doc_new") + self.assertIn("docs", helpers.all_paths(vfs)) + self.assertIn("docs/c.txt", helpers.all_paths(vfs)) + + def test_mv_dir_updates_children_parent_paths(self): + vfs = self._seed() + vfs.mv_dir("doc", "moved") + + parents = helpers.path_parents(vfs) + self.assertEqual("moved", parents["moved/a.txt"]) + self.assertEqual("moved", parents["moved/sub"]) + self.assertEqual("moved/sub", parents["moved/sub/b.txt"]) + + def test_mv_dir_into_existing_parent_directory(self): + vfs = self._seed() + vfs.mk_dir("nested") + vfs.mv_dir("doc", "nested/doc") + self.assertIn("nested/doc", helpers.all_paths(vfs)) + self.assertIn("nested/doc/a.txt", helpers.all_paths(vfs)) + self.assertEqual("nested", helpers.path_parents(vfs)["nested/doc"]) + + def test_mv_dir_to_missing_parent_raises(self): + """目标的父目录不存在时要明确报错,而不是造出一个不可达的目录。""" + vfs = self._seed() + with self.assertRaises(NotADirectoryError): + vfs.mv_dir("doc", "nested/doc") + self.assertIn("doc", helpers.all_paths(vfs)) + + def test_mv_dir_into_itself_raises(self): + vfs = self._seed() + with self.assertRaises(ValueError): + vfs.mv_dir("doc", "doc/inner") + + def test_mv_dir_root_creates_destination_and_fixes_parent_paths(self): + """P1-3: 把根目录整体移进 archive 时,archive 目录行必须存在, + 且顶层条目的 parent_path 不能带尾斜杠('archive/')。""" + vfs = self._seed() + vfs.mv_dir("/", "archive") + + parents = helpers.path_parents(vfs) + self.assertIn("archive", parents, "目标目录 archive 没有对应的目录行") + # archive 自己是顶层条目,parent_path 约定为空 + self.assertEqual("", parents["archive"]) + self.assertEqual("archive", parents["archive/doc"]) + self.assertEqual("archive", parents["archive/docs"]) + self.assertEqual("archive", parents["archive/top.txt"]) + self.assertEqual("archive/doc", parents["archive/doc/a.txt"]) + self.assertEqual("archive/doc/sub", parents["archive/doc/sub/b.txt"]) + + self.assertEqual( + sorted(["archive"]), + sorted(row["path"] for row in vfs.list_directory("/")), + ) + + def test_mv_dir_to_existing_destination_raises_and_keeps_source(self): + vfs = self._seed() + with self.assertRaises(Exception): + vfs.mv_dir("doc", "docs") + paths = helpers.all_paths(vfs) + self.assertIn("doc", paths) + self.assertIn("doc/a.txt", paths) + self.assertIn("docs", paths) + + def test_mv_file_updates_name_and_parent(self): + vfs = self._seed() + vfs.mv_file("top.txt", "docs/moved.txt") + parents = helpers.path_parents(vfs) + self.assertNotIn("top.txt", parents) + self.assertIn("docs/moved.txt", parents) + self.assertEqual("docs", parents["docs/moved.txt"]) + + +class MkDirTest(helpers.VFSTestCase): + """P1-4:mk_dir 重复创建要有明确报错,不能留下悬挂事务。""" + + def test_mk_dir_creates_directory(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + vfs.mk_dir("new_dir") + self.assertTrue(vfs.is_dir("new_dir")) + self.assertEqual(["new_dir"], [row["path"] for row in vfs.list_directory("/")]) + + def test_mk_dir_existing_raises_cleanly(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + vfs.mk_dir("new_dir") + with self.assertRaises(Exception): + vfs.mk_dir("new_dir") + # 连接仍然可用,后续写入不受影响 + vfs.mk_dir("another") + self.assertTrue(vfs.is_dir("another")) + + +class AddFileConflictTest(helpers.VFSTestCase): + """P1-4:同一路径重复写入要能被调用方感知,而不是静默失败。""" + + def test_duplicate_add_file_is_reported_and_keeps_first_content(self): + src = helpers.write_file(self.path("a.bin"), b"first") + src2 = helpers.write_file(self.path("b.bin"), b"second") + + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + with helpers.quiet(): + first = vfs.add_file(src, "same.bin") + second = vfs.add_file(src2, "same.bin") + + self.assertTrue(first, "首次写入应当成功") + self.assertFalse(second, "重复路径的写入应当失败并返回 False") + self.assertEqual(1, helpers.count_files(vfs)) + self.assertEqual(b"first", vfs.read_file("same.bin")) + # 失败后连接仍可用 + with helpers.quiet(): + self.assertTrue(vfs.add_file(src2, "other.bin")) + self.assertEqual(2, helpers.count_files(vfs)) + + +class ExportTest(helpers.VFSTestCase): + """P0-2 / P1-6 / P1-10:导出的目录树必须与库内容严格一致。""" + + def _vfs(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, + [ + {"path": "doc", "is_directory": True}, + {"path": "doc/a.txt", "content": b"a"}, + {"path": "doc/sub", "is_directory": True}, + {"path": "doc/sub/b.txt", "content": b"b"}, + {"path": "empty_dir", "is_directory": True}, + {"path": "top.txt", "content": b"t"}, + ], + ) + return vfs + + def test_export_file_writes_exact_bytes(self): + vfs = self._vfs() + target = self.path("out/top.txt") + with helpers.quiet(): + self.assertTrue(vfs.export_file("top.txt", target)) + with open(target, "rb") as f: + self.assertEqual(b"t", f.read()) + + def test_export_file_accepts_bare_filename(self): + """P1-6: local_path 没有目录部分时 dirname 为 '',不能抛 FileNotFoundError。""" + vfs = self._vfs() + cwd = os.getcwd() + os.chdir(self.tmp) + try: + with helpers.quiet(): + ok = vfs.export_file("top.txt", "bare.txt") + self.assertTrue(ok, "导出到当前目录下的裸文件名应当成功") + self.assertTrue(os.path.exists("bare.txt")) + finally: + os.chdir(cwd) + + def test_export_directory_full_tree(self): + vfs = self._vfs() + out = self.path("out") + with helpers.quiet(): + vfs.export_directory("/", out) + + expected = { + "doc": None, + "doc/a.txt": b"a", + "doc/sub": None, + "doc/sub/b.txt": b"b", + "empty_dir": None, + "top.txt": b"t", + } + helpers.assert_tree_equal(self, expected, helpers.read_tree(out)) + + def test_export_subdirectory_does_not_recurse_forever(self): + """P0-2: list_directory 按 path 过滤时,导出 doc 会无限自我递归。""" + vfs = self._vfs() + out = self.path("out") + with helpers.quiet(): + vfs.export_directory("doc", out) + + expected = {"a.txt": b"a", "sub": None, "sub/b.txt": b"b"} + helpers.assert_tree_equal(self, expected, helpers.read_tree(out)) + + def test_export_filesystem_matches_tree(self): + vfs = self._vfs() + out = self.path("out") + with helpers.quiet(): + vfs.export_filesystem(out) + expected = { + "doc": None, + "doc/a.txt": b"a", + "doc/sub": None, + "doc/sub/b.txt": b"b", + "empty_dir": None, + "top.txt": b"t", + } + helpers.assert_tree_equal(self, expected, helpers.read_tree(out)) + + +class SchemaCompatibilityTest(helpers.VFSTestCase): + """P1-7:旧格式数据库必须被明确拒绝,而不是连上之后静默失败。""" + + def test_synthesized_old_schema_db_is_rejected(self): + err = helpers.vfs_format_error() + if err is None: + self.fail( + "core 没有定义 VFSFormatError 一类的异常," + "调用方无法区分「旧格式库」与「正常库」,只能在导出阶段看到一堆导出失败" + ) + db = helpers.make_old_schema_db(self.path("legacy.svfs")) + with self.assertRaises(err) as ctx: + SQLiteVFS(db) + # 报错必须直接给出下一步动作,而不是只说"格式不对" + self.assertIn("vfsmigrate", str(ctx.exception)) + + def test_v2_schema_db_is_rejected_with_migration_hint(self): + db = helpers.make_v2_schema_db( + self.path("v2.svfs"), rows=[{"path": "a.txt", "content": b"a"}] + ) + with self.assertRaises(helpers.vfs_format_error()) as ctx: + SQLiteVFS(db) + self.assertIn("vfsmigrate", str(ctx.exception)) + + def test_repository_sample_db_is_rejected(self): + if not os.path.exists(helpers.SAMPLE_DB): + self.skipTest("仓库内没有 appcation.svfs") + err = helpers.vfs_format_error() + if err is None: + self.fail("core 没有定义 VFSFormatError 一类的异常") + with self.assertRaises(err): + SQLiteVFS(helpers.SAMPLE_DB) + + def test_fresh_database_is_accepted(self): + vfs = SQLiteVFS(self.path("fresh.svfs")) + self.addCleanup(vfs.close) + helpers.seed_rows(vfs, [{"path": "a.txt", "content": b"x"}]) + self.assertTrue(vfs.exists("a.txt")) + + def test_non_sqlite_file_is_rejected(self): + junk = helpers.write_file(self.path("junk.svfs"), b"this is not a database") + with self.assertRaises(sqlite3.DatabaseError): + SQLiteVFS(junk) + + def test_legacy_pack_is_readable_after_migration(self): + """迁移工具必须真的能读懂最早那版格式(compressed 布尔 + zlib)。 + + 详细覆盖见 test_migration.py;这里只保证"v1 库能被迁到当前版本且内容一致"。 + """ + import zlib + + from sqlite_vfs import migration + + payload = b"legacy compressed content " * 50 + db = helpers.make_old_schema_db( + self.path("legacy.svfs"), + extra_rows=[ + { + "path": "packed.txt", + "content": zlib.compress(payload), + "compressed": True, + "file_size": len(payload), + } + ], + ) + result = migration.migrate(db) + self.assertTrue(result["migrated"]) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual( + payload, vfs.read_file("packed.txt"), "旧版 zlib 压缩的数据应当能读出来" + ) + self.assertEqual( + b"hello", vfs.read_file("legacy.txt"), "旧版未压缩的数据应当能读出来" + ) + self.assertEqual("zlib", vfs.get_blob_info("packed.txt")["compress_algo"]) + + +class TimestampStorageTest(helpers.VFSTestCase): + """P2-4:时间戳必须显式转成字符串,不能依赖 sqlite3 已弃用的默认 adapter。 + + 这条同时钉住两件事:**不再有弃用警告**,以及**盘上格式与历史保持一致** + (格式一变,旧库的读取与快照摘要都会受影响)。 + """ + + #: sqlite3 默认 adapter 当年的输出格式就是 val.isoformat(" ") + STORED_TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$") + + def _exercise_every_write_path(self, vfs): + src = helpers.write_file(self.path("in.txt"), b"payload") + with helpers.quiet(): + vfs.add_file(src, "in.txt") + vfs.mk_dir("made") + vfs.add_directory("sub", self.path(".")) + vfs.create_snapshot("v2", copy_entries=True) + vfs.sign("k") + + def test_no_deprecation_warning_from_any_write_path(self): + vfs = SQLiteVFS(self.path("t.svfs")) + self.addCleanup(vfs.close) + with warnings.catch_warnings(): + # 任何 DeprecationWarning 都会变成错误——包括 sqlite3 的 adapter 警告 + warnings.simplefilter("error", DeprecationWarning) + self._exercise_every_write_path(vfs) + + def test_stored_text_keeps_the_historical_format(self): + vfs = SQLiteVFS(self.path("fmt.svfs")) + self.addCleanup(vfs.close) + src = helpers.write_file(self.path("in.txt"), b"payload") + with helpers.quiet(): + vfs.add_file(src, "in.txt") + vfs.create_snapshot("v2", copy_entries=True) + vfs.sign("k") + + for value in vfs.conn.execute( + "SELECT created_time, modified_time FROM files WHERE path = 'in.txt'" + ).fetchone(): + self.assertRegex(value, self.STORED_TIME_RE, "files 时间戳格式变了") + + self.assertRegex( + vfs.conn.execute( + "SELECT created_time FROM snapshots WHERE id = 2" + ).fetchone()[0], + self.STORED_TIME_RE, + "snapshots 时间戳格式变了", + ) + self.assertRegex( + vfs.conn.execute("SELECT signed_time FROM signatures LIMIT 1").fetchone()[0], + self.STORED_TIME_RE, + "signatures 时间戳格式变了", + ) + + def test_stored_text_parses_back_to_the_file_mtime(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + src = helpers.write_file(self.path("in.txt"), b"payload") + with helpers.quiet(): + vfs.add_file(src, "in.txt") + + raw = vfs.conn.execute( + "SELECT modified_time FROM files WHERE path = 'in.txt'" + ).fetchone()[0] + parsed = SQLiteVFS.parse_time(raw) + self.assertIsNotNone(parsed) + self.assertAlmostEqual(os.path.getmtime(src), parsed.timestamp(), delta=2) + # get_file_info 走的也是同一条解析路径 + self.assertAlmostEqual( + os.path.getmtime(src), + vfs.get_file_info("in.txt")["modified_time"].timestamp(), + delta=2, + ) + + +class ReadFileBoundaryTest(helpers.VFSTestCase): + """read_file 的 offset/size 边界语义(此前只有间接覆盖)。""" + + DATA = b"0123456789" + + def _vfs(self, compress=False): + vfs = SQLiteVFS(":memory:", compress=compress) + self.addCleanup(vfs.close) + helpers.seed_rows(vfs, [{"path": "n.bin", "content": self.DATA}]) + return vfs + + def test_full_read(self): + self.assertEqual(self.DATA, self._vfs().read_file("n.bin")) + + def test_zero_size(self): + self.assertEqual(b"", self._vfs().read_file("n.bin", 0, 0)) + + def test_offset_in_the_middle(self): + self.assertEqual(b"456789", self._vfs().read_file("n.bin", 4)) + + def test_size_larger_than_remaining_clamps(self): + self.assertEqual(b"789", self._vfs().read_file("n.bin", 7, 999)) + + def test_offset_at_eof_returns_empty(self): + self.assertEqual(b"", self._vfs().read_file("n.bin", len(self.DATA))) + + def test_offset_beyond_eof_returns_empty(self): + self.assertEqual(b"", self._vfs().read_file("n.bin", len(self.DATA) + 100)) + + def test_negative_offset_is_rejected(self): + """负数会走 Python 切片语义(从末尾数),对调用方是陷阱,直接拒绝。""" + with self.assertRaises(ValueError) as ctx: + self._vfs().read_file("n.bin", -3) + self.assertIn("offset", str(ctx.exception)) + + def test_negative_size_is_rejected(self): + with self.assertRaises(ValueError) as ctx: + self._vfs().read_file("n.bin", 0, -1) + self.assertIn("size", str(ctx.exception)) + + def test_boundary_semantics_hold_for_compressed_content(self): + """压缩路径下 offset/size 作用的是**解压后**的字节。""" + data = b"repeat me please. " * 500 + vfs = SQLiteVFS(":memory:", compress=True) + self.addCleanup(vfs.close) + src = helpers.write_file(self.path("big.bin"), data) + with helpers.quiet(): + vfs.add_file(src, "big.bin") + self.assertEqual("zlib", vfs.get_blob_info("big.bin")["compress_algo"]) + + self.assertEqual(data[:10], vfs.read_file("big.bin", 0, 10)) + self.assertEqual(data[100:130], vfs.read_file("big.bin", 100, 30)) + self.assertEqual(data[-5:], vfs.read_file("big.bin", len(data) - 5)) + with self.assertRaises(ValueError): + vfs.read_file("big.bin", -1) + + +class BlobPrimitiveTest(helpers.VFSTestCase): + """内容寻址读原语:list_blobs / read_blob / all_entries / get_blob_info。""" + + def _vfs(self, compress=True): + vfs = SQLiteVFS(":memory:", compress=compress) + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, + [ + {"path": "a.txt", "content": b"same"}, + {"path": "dup.txt", "content": b"same"}, + {"path": "sub/b.txt", "content": b"other"}, + {"path": "sub", "is_directory": True}, + ], + ) + return vfs + + def test_list_blobs_returns_one_row_per_unique_content(self): + vfs = self._vfs() + hashes = [b["hash"] for b in vfs.list_blobs()] + self.assertEqual(2, len(hashes), "相同内容只应有一行") + self.assertEqual(sorted(hashes), hashes, "应当按 hash 排序") + + def test_list_blobs_reports_sizes_without_content(self): + vfs = self._vfs() + for info in vfs.list_blobs(): + self.assertEqual( + {"hash", "size", "stored_size", "compress_algo"}, set(info) + ) + + def test_read_blob_returns_original_bytes(self): + vfs = self._vfs() + digest = vfs.get_blob_info("a.txt")["hash"] + self.assertEqual(b"same", vfs.read_blob(digest)) + self.assertEqual(b"same", vfs.read_blob(digest, verify_hash=True)) + + def test_read_blob_unknown_hash_raises(self): + with self.assertRaises(FileNotFoundError): + self._vfs().read_blob("0" * 64) + + def test_read_blob_detects_tampering_when_asked(self): + vfs = self._vfs(compress=False) + digest = vfs.get_blob_info("sub/b.txt")["hash"] + # 换成同样长度的别的内容:大小检查看不出来,只有哈希校验能发现 + vfs.conn.execute( + "UPDATE blobs SET content = X'0000000000' WHERE hash = ?", (digest,) + ) + vfs.conn.commit() + + self.assertEqual( + b"\x00" * 5, vfs.read_blob(digest), "不校验时按原样返回(结构性检查放行)" + ) + with self.assertRaises(VFSIntegrityError) as ctx: + vfs.read_blob(digest, verify_hash=True) + self.assertIn("哈希不匹配", str(ctx.exception)) + + def test_read_blob_detects_a_size_mismatch_without_asking(self): + """大小对不上是便宜的结构性检查,不需要调用方显式要求。""" + vfs = self._vfs(compress=False) + digest = vfs.get_blob_info("sub/b.txt")["hash"] + vfs.conn.execute( + "UPDATE blobs SET content = X'00' WHERE hash = ?", (digest,) + ) + vfs.conn.commit() + with self.assertRaises(VFSIntegrityError) as ctx: + vfs.read_blob(digest) + self.assertIn("大小", str(ctx.exception)) + + def test_all_entries_covers_the_whole_tree(self): + vfs = self._vfs() + self.assertEqual( + ["a.txt", "dup.txt", "sub", "sub/b.txt"], + [e["path"] for e in vfs.all_entries()], + ) + kinds = {e["path"]: e["is_directory"] for e in vfs.all_entries()} + self.assertEqual(1, kinds["sub"]) + self.assertEqual(0, kinds["a.txt"]) + + def test_all_entries_is_snapshot_scoped(self): + vfs = self._vfs() + vfs.create_snapshot("v2") + self.assertEqual([], vfs.all_entries()) + self.assertEqual(4, len(vfs.all_entries(snapshot_id=1))) + + def test_get_blob_info_returns_none_for_a_directory(self): + self.assertIsNone(self._vfs().get_blob_info("sub")) + + def test_get_blob_info_returns_none_for_a_missing_path(self): + self.assertIsNone(self._vfs().get_blob_info("nope")) + + +class AddDirectoryContractTest(helpers.VFSTestCase): + """add_directory 的返回值与 replace 语义(此前只有间接覆盖)。""" + + def _vfs(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.make_tree(self.path("src"), {"sub/deep": None}) + return vfs + + def test_returns_true_and_creates_the_row(self): + vfs = self._vfs() + self.assertTrue(vfs.add_directory("sub", self.path("src"))) + self.assertTrue(vfs.is_dir("sub")) + self.assertEqual("", helpers.path_parents(vfs)["sub"]) + + def test_returns_false_for_a_missing_source_directory(self): + vfs = self._vfs() + with helpers.quiet(): + self.assertFalse(vfs.add_directory("nope", self.path("src"))) + self.assertFalse(vfs.exists("nope")) + self.assertEqual(set(), helpers.all_paths(vfs), "失败不该留下半条记录") + + def test_duplicate_without_replace_fails_cleanly(self): + vfs = self._vfs() + self.assertTrue(vfs.add_directory("sub", self.path("src"))) + with helpers.quiet(): + self.assertFalse( + vfs.add_directory("sub", self.path("src")), "重复路径应当失败" + ) + self.assertEqual(1, len(helpers.all_paths(vfs))) + self.assertTrue(vfs.is_dir("sub"), "原记录仍在") + + def test_replace_updates_in_place(self): + vfs = self._vfs() + vfs.add_directory("sub", self.path("src")) + vfs.conn.execute("UPDATE files SET permissions = 0 WHERE path = 'sub'") + vfs.conn.commit() + + self.assertTrue(vfs.add_directory("sub", self.path("src"), replace=True)) + self.assertEqual(1, len(helpers.all_paths(vfs)), "replace 不该留下两行") + self.assertNotEqual(0, vfs.get_file_info("sub")["permissions"]) + + def test_connection_survives_a_failed_insert(self): + vfs = self._vfs() + vfs.add_directory("sub", self.path("src")) + with helpers.quiet(): + vfs.add_directory("sub", self.path("src")) + self.assertTrue(vfs.add_directory("deep", self.path("src", "sub"))) + + +class CleanTest(helpers.VFSTestCase): + """clean() 此前只被 packer 间接调用过。""" + + def test_clean_wipes_every_table(self): + vfs = SQLiteVFS(self.path("c.svfs")) + self.addCleanup(vfs.close) + helpers.seed_rows(vfs, [{"path": "a.txt", "content": b"a"}]) + vfs.add_vfs_metadata("pack") + vfs.create_snapshot("v2", copy_entries=True) + vfs.sign("k") + + vfs.clean() + + for table in ("files", "blobs", "snapshots", "signatures", "filesystem_metadata"): + with self.subTest(table=table): + count = vfs.conn.execute( + f"SELECT COUNT(*) AS c FROM {table}" + ).fetchone()["c"] + self.assertEqual(0, count, f"{table} 没被清空") + self.assertIsNone(vfs.snapshot_id) + self.assertEqual((0, 0), (vfs.total_files, vfs.total_size)) + + def test_pack_after_clean_starts_fresh(self): + db = self.path("reuse.svfs") + src = helpers.make_tree(self.path("src"), {"a.txt": b"a"}) + helpers.pack(src, db, name="first") + + vfs = SQLiteVFS(db) + vfs.clean() + vfs.add_vfs_metadata("second") + helpers.seed_rows(vfs, [{"path": "b.txt", "content": b"b"}]) + self.assertEqual({"b.txt"}, helpers.all_paths(vfs)) + self.assertEqual("second", vfs.get_stats()["name"]) + self.assertIsNone(vfs.get_file_info("a.txt")) + vfs.close() + + +class ExistsHelpersTest(helpers.VFSTestCase): + """exists / is_dir / is_file 三个判定(is_file 在 src 里没有任何调用方)。""" + + def test_all_three_agree_with_the_entry_type(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, [{"path": "d", "is_directory": True}, {"path": "f", "content": b"x"}] + ) + + self.assertTrue(vfs.exists("d")) + self.assertTrue(vfs.is_dir("d")) + self.assertFalse(vfs.is_file("d")) + + self.assertTrue(vfs.exists("f")) + self.assertFalse(vfs.is_dir("f")) + self.assertTrue(vfs.is_file("f")) + + for missing in ("nope", "d/nope"): + with self.subTest(missing=missing): + self.assertFalse(vfs.exists(missing)) + self.assertFalse(vfs.is_dir(missing)) + self.assertFalse(vfs.is_file(missing)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000..b48e631 --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,212 @@ +"""内容寻址与去重。 + +核心承诺:**相同内容只存一份**。这里用 blob 行数与 stored_size 直接验证, +而不是只看读回来的字节——字节对不代表没多存。 +""" + +import os +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS + + +class DedupTest(helpers.VFSTestCase): + def _add(self, vfs, name, data): + src = helpers.write_file(self.path(f"src_{name.replace('/', '_')}"), data) + with helpers.quiet(): + self.assertTrue(vfs.add_file(src, name), f"写入失败: {name}") + + def _vfs(self, **kwargs): + vfs = SQLiteVFS(":memory:", **kwargs) + self.addCleanup(vfs.close) + return vfs + + def test_identical_files_share_one_blob(self): + vfs = self._vfs() + self._add(vfs, "a.txt", b"same content") + self._add(vfs, "b.txt", b"same content") + self._add(vfs, "c.txt", b"same content") + + self.assertEqual(3, helpers.count_files(vfs)) + self.assertEqual(1, helpers.blob_count(vfs), "相同内容应当只占一行 blob") + + def test_identical_content_in_different_directories_shares_blob(self): + vfs = self._vfs() + self._add(vfs, "doc/logo.png", b"PNGDATA" * 100) + self._add(vfs, "assets/logo.png", b"PNGDATA" * 100) + self.assertEqual(1, helpers.blob_count(vfs)) + + def test_distinct_content_gets_distinct_blobs(self): + vfs = self._vfs() + self._add(vfs, "a.txt", b"aaa") + self._add(vfs, "b.txt", b"bbb") + self.assertEqual(2, helpers.blob_count(vfs)) + + def test_empty_files_share_one_blob(self): + vfs = self._vfs() + self._add(vfs, "e1.txt", b"") + self._add(vfs, "e2.txt", b"") + self.assertEqual(1, helpers.blob_count(vfs)) + self.assertEqual(b"", vfs.read_file("e1.txt")) + + def test_replacing_with_identical_content_does_not_add_blob(self): + vfs = self._vfs() + self._add(vfs, "a.txt", b"payload") + vfs.remove("a.txt") + self._add(vfs, "a.txt", b"payload") + self.assertEqual(1, helpers.blob_count(vfs), "重新写入相同内容不该新增 blob") + + def test_rewrite_different_content_keeps_only_new_blob(self): + vfs = self._vfs() + self._add(vfs, "a.txt", b"first") + vfs.remove("a.txt") + self._add(vfs, "a.txt", b"second") + self.assertEqual(1, helpers.blob_count(vfs), "旧内容已经没人引用,应当被回收") + + def test_blob_records_original_and_stored_size(self): + vfs = self._vfs(compress=True) + data = b"compressible text " * 200 + self._add(vfs, "big.txt", data) + blob = vfs.get_blob_info("big.txt") + self.assertEqual(len(data), blob["size"], "size 记的是原始大小") + self.assertLess(blob["stored_size"], len(data), "stored_size 记的是实际占用") + self.assertEqual("zlib", blob["compress_algo"]) + + def test_dedup_skips_recompression(self): + """第二次写入相同内容时不该再压一遍——这是增量打包能变快的原因。""" + vfs = self._vfs(compress=True) + data = b"same bytes " * 500 + src = helpers.write_file(self.path("in.bin"), data) + + with helpers.quiet(): + vfs.add_file(src, "one.bin") + first = vfs.get_blob_info("one.bin") + vfs.add_file(src, "two.bin") + second = vfs.get_blob_info("two.bin") + + self.assertEqual(first["hash"], second["hash"]) + self.assertEqual(1, helpers.blob_count(vfs)) + # 两个路径指向同一份压缩数据,占用没有翻倍 + self.assertEqual(first["stored_size"], second["stored_size"]) + + def test_repacking_identical_tree_does_not_grow_storage(self): + src = helpers.make_tree( + self.path("tree"), + {"a.txt": b"hello", "b.txt": b"hello", "sub/c.txt": b"world"}, + ) + db = self.path("p.svfs") + helpers.pack(src, db, compress=True) + first_size = os.path.getsize(db) + vfs = SQLiteVFS(db) + self.assertEqual(2, helpers.blob_count(vfs), "hello 与 world 各一份") + vfs.close() + + helpers.pack(src, db, compress=True) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(2, helpers.blob_count(vfs)) + self.assertEqual(3, helpers.count_files(vfs)) + self.assertLess( + os.path.getsize(db), + first_size * 1.5, + "重打同一棵树不该让库明显变大", + ) + + +class BlobGarbageCollectionTest(helpers.VFSTestCase): + def _vfs(self, **kwargs): + vfs = SQLiteVFS(":memory:", **kwargs) + self.addCleanup(vfs.close) + helpers.seed_rows( + vfs, + [ + {"path": "keep.txt", "content": b"keep"}, + {"path": "shared.txt", "content": b"shared"}, + {"path": "also_shared.txt", "content": b"shared"}, + {"path": "temp", "is_directory": True}, + {"path": "temp/gone.txt", "content": b"gone"}, + ], + ) + return vfs + + def test_remove_file_collects_unreferenced_blob(self): + vfs = self._vfs() + self.assertEqual(3, helpers.blob_count(vfs)) + vfs.remove("temp/gone.txt") + self.assertEqual(2, helpers.blob_count(vfs), "'gone' 已无人引用,应被回收") + + def test_remove_directory_collects_blobs(self): + vfs = self._vfs() + vfs.remove("temp") + self.assertEqual(2, helpers.blob_count(vfs)) + + def test_shared_blob_survives_partial_removal(self): + vfs = self._vfs() + vfs.remove("shared.txt") + self.assertEqual( + 3, helpers.blob_count(vfs), "'shared' 还有另一个路径在用,不能回收" + ) + self.assertEqual(b"shared", vfs.read_file("also_shared.txt")) + + def test_gc_keeps_blobs_referenced_by_another_snapshot(self): + """多快照共用内容时,删掉一个版本不能影响另一个版本。""" + vfs = self._vfs() + second = vfs.create_snapshot("v2", copy_entries=True) + + # 在 v2 里删掉一部分,v1 仍然引用着这些内容 + vfs.remove("temp/gone.txt") + vfs.remove("shared.txt") + self.assertEqual( + 3, helpers.blob_count(vfs), "v1 还在引用,blob 不该被回收" + ) + + vfs.use_snapshot(1) + self.assertEqual(b"gone", vfs.read_file("temp/gone.txt")) + self.assertEqual(b"shared", vfs.read_file("shared.txt")) + self.assertEqual(second, 2) + + def test_deleting_snapshot_allows_gc(self): + """只被某个快照引用的内容,在删掉那个快照后应当被回收。""" + vfs = self._vfs() + vfs.create_snapshot("v2") # 空快照,不与 v1 共享内容 + src = helpers.write_file(self.path("v2only.bin"), b"v2-only-content") + with helpers.quiet(): + vfs.add_file(src, "only-in-v2.txt") + self.assertEqual(4, helpers.blob_count(vfs)) + + vfs.delete_snapshot(2) + self.assertEqual( + 3, helpers.blob_count(vfs), "只被 v2 引用的内容应当被回收" + ) + self.assertFalse(vfs.exists("only-in-v2.txt")) + # v1 完全不受影响 + self.assertEqual(b"shared", vfs.read_file("shared.txt")) + self.assertEqual(b"gone", vfs.read_file("temp/gone.txt")) + + def test_gc_returns_deleted_count(self): + vfs = self._vfs() + # 直接造一个没人引用的 blob + vfs.conn.execute( + "INSERT INTO blobs (hash, size, stored_size, content) " + "VALUES ('orphan', 1, 1, X'00')" + ) + vfs.conn.commit() + self.assertEqual(1, vfs.gc_blobs(), "应当回收到孤立的 blob") + self.assertEqual(0, vfs.gc_blobs(), "再跑一次应当没有可回收的") + + def test_clear_snapshot_keeps_other_snapshot_content(self): + vfs = self._vfs() + vfs.create_snapshot("v2", copy_entries=True) + vfs.clear_snapshot() + self.assertEqual(0, helpers.count_files(vfs), "当前快照已清空") + self.assertEqual(3, helpers.blob_count(vfs), "v1 还在引用") + + vfs.use_snapshot(1) + self.assertEqual(5, len(helpers.all_paths(vfs))) + self.assertEqual(b"keep", vfs.read_file("keep.txt")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fastapi_static.py b/tests/test_fastapi_static.py new file mode 100644 index 0000000..f76ffda --- /dev/null +++ b/tests/test_fastapi_static.py @@ -0,0 +1,483 @@ +"""SVFSStaticFiles(FastAPI / Starlette 静态文件插件)的集成测试。 + +没有安装 fastapi / starlette / httpx 时整个模块会被跳过, +这样"插件是可选依赖"的约束在测试层面也成立 —— 核心包不因为它的测试而需要装框架。 + +用真实 ASGI 栈(FastAPI + TestClient)跑请求,而不是直接调内部方法, +这样 405 / 重定向 / 条件请求 / Range 这些行为都按 HTTP 语义被验证。 +""" + +import hashlib +import os +import struct +import unittest + +import helpers + +try: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles + + AVAILABLE = True + IMPORT_ERROR = None +except ImportError as exc: # pragma: no cover + AVAILABLE = False + IMPORT_ERROR = exc + + +#: 资源包内容:覆盖 MIME 覆盖表、可压缩大文件、空文件、前端壳、嵌套目录 +ASSETS = { + "index.html": b"app
shell", + "app.js": b"console.log('hi');", + "app.mjs": b"export default 1;", + "style.css": b"body{margin:0}", + "font.woff2": b"\x77\x4f\x46\x32\x00\x01", + "data.json": b'{"ok":true}', + "empty.txt": b"", + "big.txt": b"0123456789abcdef" * 2000, # 32KB,会被哈夫曼压缩 + "sub/page.html": b"

sub page

", + "sub/index.html": b"

sub index

", + "assets/logo.png": b"\x89PNG\r\n\x1a\n", +} + + +@unittest.skipUnless(AVAILABLE, f"需要 fastapi/starlette/httpx: {IMPORT_ERROR}") +class NeedsFastAPI(helpers.VFSTestCase): + """缺少框架时整类跳过(子类通过属性继承同样被跳过)。""" + + +class PluginTestCase(NeedsFastAPI): + """构造一个真实的 .svfs 资源包,并把它挂在 FastAPI 上当静态目录。""" + + compress = True + algorithm = None # None = 用默认算法(zlib) + spa = True + html = True + + def setUp(self): + super().setUp() + self.src = helpers.make_tree(self.path("dist"), ASSETS) + self.db = helpers.pack( + self.src, + self.path("static.svfs"), + compress=self.compress, + name="assets", + algorithm=self.algorithm, + ) + self.md5_before = helpers.md5_of(self.db) + self.statics = SVFSStaticFiles(self.db, html=self.html, spa=self.spa) + self.addCleanup(self.statics.close) + + app = FastAPI() + app.mount("/", self.statics, name="static") + self.client = TestClient(app, follow_redirects=False) + + def get(self, path, **kwargs): + return self.client.get(path, headers=kwargs.pop("headers", None), **kwargs) + + def assert_unmodified(self): + """所有请求都必须是只读的,资源包不能被改动。""" + self.assertEqual(self.md5_before, helpers.md5_of(self.db)) + + +class BasicServingTest(PluginTestCase): + def test_serves_top_level_file(self): + response = self.get("/app.js") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["app.js"], response.content) + self.assertEqual(b"console.log('hi');", response.content) + + def test_serves_nested_file(self): + response = self.get("/sub/page.html") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["sub/page.html"], response.content) + + def test_serves_empty_file(self): + response = self.get("/empty.txt") + self.assertEqual(200, response.status_code) + self.assertEqual(b"", response.content) + self.assertEqual("0", response.headers["content-length"]) + + def test_serves_compressed_file_correctly(self): + """大文件在库里是哈夫曼压缩存储的,取出来必须还原成原始字节。""" + response = self.get("/big.txt") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + + def test_content_length_matches_body(self): + response = self.get("/big.txt") + self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"]) + + +class ContentTypeTest(PluginTestCase): + def test_web_types_are_not_platform_dependent(self): + """Windows 注册表会把 .mjs 判成 text/plain(浏览器会拒绝执行模块), + 这里断言插件用的是显式覆盖表。""" + expected = { + "/app.js": "text/javascript", + "/app.mjs": "text/javascript", + "/style.css": "text/css", + "/index.html": "text/html", + "/data.json": "application/json", + "/font.woff2": "font/woff2", + "/assets/logo.png": "image/png", + } + for path, content_type in expected.items(): + with self.subTest(path=path): + response = self.get(path) + self.assertEqual(200, response.status_code) + self.assertEqual( + content_type, response.headers["content-type"].split(";")[0] + ) + + def test_unknown_extension_falls_back_to_octet_stream(self): + helpers.write_file(os.path.join(self.src, "blob.bin"), b"\x00\x01") + helpers.pack(self.src, self.db, compress=self.compress) + self.assertEqual( + "application/octet-stream", + self.get("/blob.bin").headers["content-type"].split(";")[0], + ) + + +class IndexAndSpaTest(PluginTestCase): + def test_root_serves_index_html(self): + response = self.get("/") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["index.html"], response.content) + self.assertEqual("text/html", response.headers["content-type"].split(";")[0]) + + def test_directory_redirects_to_trailing_slash(self): + response = self.get("/sub") + self.assertEqual(307, response.status_code) + # Starlette 生成的是绝对 URL + self.assertTrue( + response.headers["location"].endswith("/sub/"), + response.headers["location"], + ) + + def test_directory_with_slash_serves_its_index(self): + response = self.get("/sub/") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["sub/index.html"], response.content) + + def test_spa_fallback_for_frontend_route(self): + """浏览器导航到前端路由 /settings 时应当拿到 index.html。""" + response = self.get("/settings", headers={"accept": "text/html"}) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["index.html"], response.content) + + def test_spa_fallback_for_deep_route(self): + response = self.get("/user/42/detail", headers={"accept": "text/html"}) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["index.html"], response.content) + + def test_no_fallback_for_missing_asset(self): + """缺静态资源必须是 404,否则前端拿到一份 HTML 会报诡异的解析错误。""" + self.assertEqual(404, self.get("/missing.js").status_code) + self.assertEqual(404, self.get("/missing.css").status_code) + + def test_no_fallback_for_json_request(self): + """XHR/接口请求(Accept 不含 text/html)不该被回退成 index.html。""" + self.assertEqual( + 404, self.get("/api/data", headers={"accept": "application/json"}).status_code + ) + + def test_missing_file_returns_404(self): + self.assertEqual(404, self.get("/nope.txt").status_code) + + def test_post_is_rejected(self): + self.assertEqual(405, self.client.post("/app.js").status_code) + + def test_path_traversal_cannot_escape_the_pack(self): + """资源包不是磁盘目录,.. 之类无法越出数据库。""" + for path in ("/../../etc/passwd", "/..%2f..%2fsecret.txt", "/sub/../../app.js"): + with self.subTest(path=path): + self.assertIn(self.get(path).status_code, (200, 404, 307)) + self.assert_unmodified() + + def test_repeated_requests_do_not_modify_pack(self): + for path in ("/", "/app.js", "/big.txt", "/sub/", "/settings"): + self.get(path) + self.assert_unmodified() + + +class StrictModeTest(PluginTestCase): + """html=False, spa=False 时行为与 StaticFiles 严格一致。""" + + html = False + spa = False + + def test_root_is_404(self): + self.assertEqual(404, self.get("/").status_code) + + def test_route_is_404(self): + self.assertEqual(404, self.get("/settings", headers={"accept": "text/html"}).status_code) + + def test_plain_file_still_served(self): + self.assertEqual(200, self.get("/app.js").status_code) + + +class ConditionalRequestTest(PluginTestCase): + def test_etag_and_last_modified_present(self): + response = self.get("/app.js") + self.assertIn("etag", response.headers) + self.assertIn("last-modified", response.headers) + self.assertEqual("bytes", response.headers["accept-ranges"]) + + def test_if_none_match_returns_304(self): + etag = self.get("/app.js").headers["etag"] + response = self.client.get("/app.js", headers={"if-none-match": etag}) + self.assertEqual(304, response.status_code) + self.assertEqual(b"", response.content) + + def test_if_none_match_with_weak_prefix(self): + etag = self.get("/app.js").headers["etag"] + response = self.client.get("/app.js", headers={"if-none-match": f"W/{etag}"}) + self.assertEqual(304, response.status_code) + + def test_if_modified_since_returns_304(self): + last_modified = self.get("/app.js").headers["last-modified"] + response = self.client.get( + "/app.js", headers={"if-modified-since": last_modified} + ) + self.assertEqual(304, response.status_code) + + def test_stale_if_none_match_returns_full_body(self): + response = self.client.get("/app.js", headers={"if-none-match": '"stale"'}) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["app.js"], response.content) + + def test_etag_is_stable_across_instances(self): + """同一个资源包重新打开,ETag 不能变,否则缓存永远失效。""" + other = SVFSStaticFiles(self.db) + self.addCleanup(other.close) + app = FastAPI() + app.mount("/", other, name="static") + client = TestClient(app) + self.assertEqual( + self.get("/app.js").headers["etag"], client.get("/app.js").headers["etag"] + ) + + def test_max_age_sets_cache_control(self): + statics = SVFSStaticFiles(self.db, max_age=3600) + self.addCleanup(statics.close) + app = FastAPI() + app.mount("/", statics, name="static") + response = TestClient(app).get("/app.js") + self.assertEqual("public, max-age=3600", response.headers["cache-control"]) + + +class HeadAndRangeTest(PluginTestCase): + def test_head_has_no_body_but_real_length(self): + response = self.client.head("/big.txt") + self.assertEqual(200, response.status_code) + self.assertEqual(b"", response.content) + self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"]) + + def test_range_returns_206_with_slice(self): + response = self.client.get("/big.txt", headers={"range": "bytes=0-9"}) + self.assertEqual(206, response.status_code) + self.assertEqual(ASSETS["big.txt"][:10], response.content) + self.assertEqual( + f"bytes 0-9/{len(ASSETS['big.txt'])}", response.headers["content-range"] + ) + self.assertEqual("10", response.headers["content-length"]) + + def test_range_with_open_end(self): + response = self.client.get("/big.txt", headers={"range": "bytes=100-"}) + self.assertEqual(206, response.status_code) + self.assertEqual(ASSETS["big.txt"][100:], response.content) + + def test_suffix_range(self): + response = self.client.get("/big.txt", headers={"range": "bytes=-16"}) + self.assertEqual(206, response.status_code) + self.assertEqual(ASSETS["big.txt"][-16:], response.content) + + def test_range_on_compressed_content_is_correct(self): + """库里存的是压缩数据,Range 必须作用在解压后的内容上。""" + for start, end in ((0, 0), (15, 31), (len(ASSETS["big.txt"]) - 5, 999999)): + with self.subTest(start=start, end=end): + response = self.client.get( + "/big.txt", headers={"range": f"bytes={start}-{end}"} + ) + self.assertEqual(206, response.status_code) + self.assertEqual(ASSETS["big.txt"][start : end + 1], response.content) + + def test_unsatisfiable_range_returns_416(self): + response = self.client.get( + "/big.txt", headers={"range": f"bytes={len(ASSETS['big.txt'])}-"} + ) + self.assertEqual(416, response.status_code) + self.assertEqual( + f"bytes */{len(ASSETS['big.txt'])}", response.headers["content-range"] + ) + + def test_malformed_range_is_ignored(self): + """语法错误的 Range 按 RFC 应当被忽略,返回整份内容。""" + for header in ("bytes=abc-def", "items=0-10", "", "bytes="): + with self.subTest(header=header): + response = self.client.get("/big.txt", headers={"range": header}) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + + def test_multi_range_falls_back_to_full_body(self): + response = self.client.get("/big.txt", headers={"range": "bytes=0-1,4-5"}) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + + def test_if_range_mismatch_ignores_range(self): + response = self.client.get( + "/big.txt", headers={"range": "bytes=0-9", "if-range": '"stale"'} + ) + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + + def test_if_range_match_uses_range(self): + etag = self.get("/big.txt").headers["etag"] + response = self.client.get( + "/big.txt", headers={"range": "bytes=0-9", "if-range": etag} + ) + self.assertEqual(206, response.status_code) + + def test_head_with_range(self): + response = self.client.head("/big.txt", headers={"range": "bytes=0-9"}) + self.assertEqual(206, response.status_code) + self.assertEqual(b"", response.content) + self.assertEqual("10", response.headers["content-length"]) + + +class CompressionAlgorithmServingTest(PluginTestCase): + """资源包用哪种算法压缩,对使用方必须完全透明。""" + + def test_pack_uses_zlib_by_default(self): + import sqlite3 + + conn = sqlite3.connect(f"file:{self.db}?mode=ro", uri=True) + try: + algorithms = { + row[0] + for row in conn.execute( + """ + SELECT b.compress_algo + FROM files f JOIN blobs b ON b.hash = f.content_hash + WHERE f.is_directory = 0 + """ + ) + } + finally: + conn.close() + self.assertIn("zlib", algorithms) + self.assertNotIn("huffman", algorithms) + + def test_large_asset_survives_compressed_roundtrip(self): + response = self.get("/big.txt") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"]) + + +class HuffmanPackServingTest(PluginTestCase): + """早期版本用 huffman 打的资源包也必须能正常服务(向后兼容)。""" + + algorithm = "huffman" + + def test_serves_huffman_compressed_asset(self): + response = self.get("/big.txt") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["big.txt"], response.content) + + def test_range_works_on_huffman_pack(self): + response = self.client.get("/big.txt", headers={"range": "bytes=0-9"}) + self.assertEqual(206, response.status_code) + self.assertEqual(ASSETS["big.txt"][:10], response.content) + + def test_conditional_request_works_on_huffman_pack(self): + etag = self.get("/big.txt").headers["etag"] + response = self.client.get("/big.txt", headers={"if-none-match": etag}) + self.assertEqual(304, response.status_code) + + +class MountPrefixTest(PluginTestCase): + """挂在子路径下(app.mount("/static", ...))也要正常工作。""" + + def setUp(self): + super().setUp() + app = FastAPI() + app.mount("/static", self.statics, name="static") + self.client = TestClient(app, follow_redirects=False) + + def test_serves_under_prefix(self): + response = self.get("/static/app.js") + self.assertEqual(200, response.status_code) + self.assertEqual(ASSETS["app.js"], response.content) + + def test_outside_prefix_is_404(self): + self.assertEqual(404, self.get("/app.js").status_code) + + +class ConstructionTest(NeedsFastAPI): + def test_missing_pack_raises_runtime_error_with_hint(self): + missing = self.path("nope.svfs") + with self.assertRaises(RuntimeError) as ctx: + SVFSStaticFiles(missing) + self.assertIn("vfspack", str(ctx.exception)) + + def test_old_format_pack_is_rejected_clearly(self): + from sqlite_vfs.core import VFSFormatError + + db = helpers.make_old_schema_db(self.path("legacy.svfs")) + with self.assertRaises(VFSFormatError): + SVFSStaticFiles(db) + + def test_non_database_file_is_rejected(self): + junk = helpers.write_file(self.path("junk.svfs"), b"not a database") + with self.assertRaises(RuntimeError): + SVFSStaticFiles(junk) + + def test_repr_and_close(self): + src = helpers.make_tree(self.path("d"), {"a.txt": b"a"}) + db = helpers.pack(src, self.path("s.svfs")) + statics = SVFSStaticFiles(db) + self.assertIn("SVFSStaticFiles", repr(statics)) + statics.close() + # 关闭后再访问会报错,而不是静默返回空内容 + with self.assertRaises(Exception): + statics.lookup_path("/a.txt") + + +class InterruptedReadTest(NeedsFastAPI): + """返回 404/304 时不能读取 BLOB(这也是条件请求省下来的开销)。""" + + def test_304_does_not_read_content(self): + src = helpers.make_tree(self.path("d"), {"a.txt": b"aaa"}) + db = helpers.pack(src, self.path("s.svfs")) + statics = SVFSStaticFiles(db) + self.addCleanup(statics.close) + + calls = [] + original = statics._read + + def counting_read(*args, **kwargs): + calls.append(args) + return original(*args, **kwargs) + + statics._read = counting_read + + app = FastAPI() + app.mount("/", statics, name="static") + client = TestClient(app) + + first = client.get("/a.txt") + self.assertEqual(200, first.status_code) + self.assertEqual(1, len(calls)) + + etag = first.headers["etag"] + not_modified = client.get("/a.txt", headers={"if-none-match": etag}) + self.assertEqual(304, not_modified.status_code) + self.assertEqual(1, len(calls), "304 响应不应该再去读 BLOB") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_huffman.py b/tests/test_huffman.py new file mode 100644 index 0000000..add65eb --- /dev/null +++ b/tests/test_huffman.py @@ -0,0 +1,130 @@ +"""huffman.py 的往返测试。 + +这个模块是独立的、只有标准库依赖的算法实现,理论上应当全绿; +它在测试里的作用是:当打包/解包链路出问题时,可以快速排除"压缩算法本身写错了" +这一可能,把问题范围锁到 core.py 的集成层。 +""" + +import random +import struct +import unittest + +import helpers # noqa: F401 -- 把 src 加入 sys.path + +from sqlite_vfs.huffman import ( + FREQ_SIZE, + MAGIC, + compress_bytes, + decompress_bytes, +) + + +class RoundTripTest(unittest.TestCase): + def assert_roundtrip(self, data, label=""): + freq_blob, compressed_blob = compress_bytes(data) + + self.assertEqual( + FREQ_SIZE, len(freq_blob), f"{label}: 频率表必须是 256 个 uint32" + ) + self.assertEqual(MAGIC, compressed_blob[:4], f"{label}: 魔数不正确") + self.assertEqual( + len(data), + struct.unpack(">Q", compressed_blob[4:12])[0], + f"{label}: 头部记录的原长度不正确", + ) + self.assertEqual( + data, decompress_bytes(freq_blob, compressed_blob), f"{label}: 还原结果不一致" + ) + return compressed_blob + + def test_empty(self): + freq_blob, compressed_blob = compress_bytes(b"") + self.assertEqual(FREQ_SIZE, len(freq_blob)) + self.assertEqual(b"", decompress_bytes(freq_blob, compressed_blob)) + + def test_single_byte(self): + self.assert_roundtrip(b"\x00", "单字节") + self.assert_roundtrip(b"Z", "单字节") + + def test_single_symbol_repeated(self): + self.assert_roundtrip(b"\x7f" * 5000, "单符号重复") + + def test_two_symbols(self): + self.assert_roundtrip(b"ab" * 3000, "双符号") + + def test_all_256_byte_values(self): + self.assert_roundtrip(bytes(range(256)), "全字节值") + self.assert_roundtrip(bytes(range(256)) * 40, "全字节值重复") + + def test_incompressible_random(self): + data = random.Random(1).randbytes(20000) + compressed_blob = self.assert_roundtrip(data, "随机数据") + self.assertGreater( + len(compressed_blob), + len(data), + "随机数据的压缩结果应当大于原始数据(这正是 core 会回退到 none 的原因)", + ) + + def test_repetitive_text_compresses(self): + data = b"the quick brown fox jumps over the lazy dog. " * 2000 + compressed_blob = self.assert_roundtrip(data, "重复文本") + # 该文本有 20 个不同字符,理论码长 ~4.5 bit/字符,压缩后约为原始的 56% + self.assertLess( + len(compressed_blob), + int(len(data) * 0.7), + f"高度重复的文本应当被显著压缩,实际 {len(compressed_blob)}/{len(data)}", + ) + + def test_utf8_chinese(self): + self.assert_roundtrip("中文内容,测试哈夫曼编码是否无损。\n".encode("utf-8") * 200, "中文") + + def test_binary_with_nulls_and_high_bytes(self): + data = bytes([0, 255, 0, 128, 64, 0, 7]) * 500 + self.assert_roundtrip(data, "二进制含 0x00/0xFF") + + def test_just_above_core_threshold(self): + # core.add_file 的压缩阈值是 1024 字节,边界两侧都要能正确往返 + self.assert_roundtrip(b"A" * 1024, "1024 字节") + self.assert_roundtrip(b"A" * 1025, "1025 字节") + + +class HeaderValidationTest(unittest.TestCase): + def setUp(self): + # 刻意用多符号数据:单符号数据在解压时走快捷路径,不读 payload + self.freq_blob, self.compressed_blob = compress_bytes( + b"the quick brown fox. " * 500 + ) + + def test_wrong_freq_blob_size_rejected(self): + with self.assertRaises(ValueError) as ctx: + decompress_bytes(self.freq_blob[:-4], self.compressed_blob) + self.assertIn("频率表", str(ctx.exception)) + + def test_too_short_blob_rejected(self): + with self.assertRaises(ValueError) as ctx: + decompress_bytes(self.freq_blob, b"HUF") + self.assertIn("太短", str(ctx.exception)) + + def test_bad_magic_rejected(self): + broken = b"XXXX" + self.compressed_blob[4:] + with self.assertRaises(ValueError) as ctx: + decompress_bytes(self.freq_blob, broken) + self.assertIn("魔数", str(ctx.exception)) + + def test_truncated_payload_rejected(self): + truncated = self.compressed_blob[:-50] + with self.assertRaises(ValueError) as ctx: + decompress_bytes(self.freq_blob, truncated) + self.assertIn("提前结束", str(ctx.exception)) + + def test_single_symbol_payload_is_not_validated(self): + """记录既有行为(非缺陷):单符号数据解压时直接按原长度展开,完全不读 payload, + 因此 payload 被截断也检测不出来。""" + freq_blob, compressed_blob = compress_bytes(b"A" * 10000) + self.assertEqual( + b"A" * 10000, decompress_bytes(freq_blob, compressed_blob[:20]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_incremental.py b/tests/test_incremental.py new file mode 100644 index 0000000..0b7f522 --- /dev/null +++ b/tests/test_incremental.py @@ -0,0 +1,337 @@ +"""增量打包。 + +约定: +* 默认按 ``mtime + size`` 判定变化(不读文件内容,快); +* ``rehash=True`` 时按 sha256 逐文件比对(可靠,但要把文件读一遍); +* 磁盘上消失的路径会被删掉,对应的 blob 也会被回收; +* 内容没变的文件不会被重新压缩或重复存储(去重的附带收益)。 +""" + +import hashlib +import os +import time +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker + + +class IncrementalTestBase(helpers.VFSTestCase): + def setUp(self): + super().setUp() + self.src = helpers.make_tree( + self.path("src"), + {"a.txt": b"aaa", "b.txt": b"bbb", "sub/c.txt": b"ccc"}, + ) + self.db = self.path("inc.svfs") + self.pack(incremental=False) + + def pack_with(self, vfs, **kwargs): + """用给定的 vfs 打包,并把统计与输出留下来。""" + packer = FolderPacker(vfs, fs_name="inc") + with helpers.quiet() as output: + ok = packer.pack_folder(self.src, **kwargs) + self.stats = packer.last_stats + self.output = output.getvalue() + return ok + + def pack(self, **kwargs): + vfs = SQLiteVFS(self.db, compress=True) + try: + return self.pack_with(vfs, **kwargs) + finally: + vfs.close() + + def read(self, path): + vfs = SQLiteVFS(self.db) + try: + return vfs.read_file(path) + finally: + vfs.close() + + def blob_hashes(self): + vfs = SQLiteVFS(self.db) + try: + return {row["hash"] for row in vfs.conn.execute("SELECT hash FROM blobs")} + finally: + vfs.close() + + def hash_for(self, path): + vfs = SQLiteVFS(self.db) + try: + return vfs.get_blob_info(path)["hash"] + finally: + vfs.close() + + def touch_later(self, path, data): + """改文件内容并把 mtime 推到 1 秒之后,避免同秒同大小的歧义。""" + helpers.write_file(path, data) + future = time.time() + 2 + os.utime(path, (future, future)) + + +class ChangeDetectionTest(IncrementalTestBase): + def test_unchanged_tree_writes_nothing(self): + self.pack(incremental=True) + self.assertEqual( + {"added": 0, "updated": 0, "unchanged": 3, "removed": 0}, + {k: self.stats[k] for k in ("added", "updated", "unchanged", "removed")}, + ) + self.assertIn("未变 3", self.output) + + def test_modified_file_is_updated(self): + self.touch_later(os.path.join(self.src, "a.txt"), b"aaa-changed") + self.pack(incremental=True) + self.assertEqual(1, self.stats["updated"]) + self.assertEqual(2, self.stats["unchanged"]) + self.assertEqual(b"aaa-changed", self.read("a.txt")) + + def test_new_file_is_added(self): + helpers.write_file(os.path.join(self.src, "new.txt"), b"new") + self.pack(incremental=True) + self.assertEqual(1, self.stats["added"]) + self.assertEqual(b"new", self.read("new.txt")) + + def test_deleted_file_is_removed(self): + os.remove(os.path.join(self.src, "b.txt")) + self.pack(incremental=True) + self.assertEqual(1, self.stats["removed"]) + + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertFalse(vfs.exists("b.txt")) + self.assertEqual(2, helpers.count_files(vfs)) + + def test_new_directory_is_created(self): + helpers.make_tree(self.src, {"fresh/deep/x.txt": b"x"}) + self.pack(incremental=True) + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertTrue(vfs.is_dir("fresh")) + self.assertTrue(vfs.is_dir("fresh/deep")) + self.assertEqual(b"x", vfs.read_file("fresh/deep/x.txt")) + + def test_deleted_directory_is_removed(self): + import shutil + + shutil.rmtree(os.path.join(self.src, "sub")) + self.pack(incremental=True) + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertFalse(vfs.exists("sub")) + self.assertFalse(vfs.exists("sub/c.txt")) + + def test_file_replaced_by_directory(self): + """同一路径从文件变成目录时必须换掉类型,而不是留着旧条目。""" + os.remove(os.path.join(self.src, "a.txt")) + helpers.make_tree(self.src, {"a.txt": None, "a.txt/inner.txt": b"inner"}) + self.pack(incremental=True) + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertTrue(vfs.is_dir("a.txt")) + self.assertEqual(b"inner", vfs.read_file("a.txt/inner.txt")) + + def test_mixed_changes_in_one_run(self): + self.touch_later(os.path.join(self.src, "a.txt"), b"aaa-v2") + helpers.write_file(os.path.join(self.src, "new.txt"), b"new") + os.remove(os.path.join(self.src, "b.txt")) + self.pack(incremental=True) + self.assertEqual(1, self.stats["added"]) + self.assertEqual(1, self.stats["updated"]) + self.assertEqual(1, self.stats["unchanged"]) + self.assertEqual(1, self.stats["removed"]) + + def test_result_still_verifies(self): + self.touch_later(os.path.join(self.src, "a.txt"), b"aaa-v2") + helpers.write_file(os.path.join(self.src, "new.txt"), b"new") + os.remove(os.path.join(self.src, "b.txt")) + self.pack(incremental=True) + + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + report = vfs.verify(check_content=True) + self.assertTrue(report["ok"], report["errors"]) + self.assertEqual(3, report["files_checked"]) + + +class NoRewriteTest(IncrementalTestBase): + """未变的文件不能被重写、重压或重新存储。""" + + def test_unchanged_file_keeps_its_blob_hash(self): + before = self.hash_for("sub/c.txt") + self.touch_later(os.path.join(self.src, "a.txt"), b"aaa-v2") + self.pack(incremental=True) + self.assertEqual(before, self.hash_for("sub/c.txt")) + + def test_unchanged_files_do_not_add_blobs(self): + before = self.blob_hashes() + self.pack(incremental=True) + self.assertEqual(before, self.blob_hashes()) + + def test_dedup_reuses_blob_for_identical_new_file(self): + """新路径但内容与已有文件相同 → 直接复用,不新增 blob。""" + before = self.blob_hashes() + helpers.write_file(os.path.join(self.src, "copy_of_c.txt"), b"ccc") + self.pack(incremental=True) + self.assertEqual(1, self.stats["added"]) + self.assertEqual(before, self.blob_hashes(), "内容相同,不该新增 blob") + + def test_replacing_content_frees_the_old_blob(self): + self.touch_later(os.path.join(self.src, "a.txt"), b"completely different") + self.pack(incremental=True) + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertEqual(3, helpers.blob_count(vfs), "旧的 aaa 已无人引用,应当回收") + self.assertEqual(b"completely different", vfs.read_file("a.txt")) + + def test_rehash_skips_files_whose_content_is_identical(self): + """mtime 变了但内容没变:rehash 模式应当判为未变,不做任何写入。""" + path = os.path.join(self.src, "a.txt") + with open(path, "rb") as f: + original = f.read() + self.touch_later(path, original) # 只改 mtime + + before = self.hash_for("a.txt") + self.pack(incremental=True, rehash=True) + self.assertEqual(3, self.stats["unchanged"]) + self.assertEqual(0, self.stats["updated"]) + self.assertEqual(before, self.hash_for("a.txt")) + + +class RehashDetectionTest(IncrementalTestBase): + def test_mtime_only_change_is_detected_without_rehash(self): + """同大小 + 同秒修改:默认模式可能漏掉,这正是 rehash 存在的理由。""" + path = os.path.join(self.src, "a.txt") + helpers.write_file(path, b"AAAA") # 与原始 aaa 大小不同,先制造同大小场景 + self.pack(incremental=True) + + # 现在 disk 与库里都是 AAAA;把内容换成同样长度的别的字节, + # 并把 mtime 强制设回库里记录的那个值 + vfs = SQLiteVFS(self.db) + recorded = vfs.get_file_info("a.txt")["modified_time"].timestamp() + vfs.close() + helpers.write_file(path, b"BBBB") + os.utime(path, (recorded, recorded)) + + self.pack(incremental=True) + self.assertEqual(3, self.stats["unchanged"], "mtime+size 相同,默认模式看不出来") + self.assertEqual(b"AAAA", self.read("a.txt"), "库里还是旧内容(预期的漏检)") + + def test_rehash_catches_same_size_same_mtime_edit(self): + path = os.path.join(self.src, "a.txt") + helpers.write_file(path, b"AAAA") + self.pack(incremental=True) + + vfs = SQLiteVFS(self.db) + recorded = vfs.get_file_info("a.txt")["modified_time"].timestamp() + vfs.close() + helpers.write_file(path, b"BBBB") + os.utime(path, (recorded, recorded)) + + self.pack(incremental=True, rehash=True) + self.assertEqual(1, self.stats["updated"], "rehash 必须抓到这次修改") + self.assertEqual(b"BBBB", self.read("a.txt")) + + +class IncrementalSnapshotTest(IncrementalTestBase): + def test_first_incremental_on_an_empty_database(self): + db = self.path("fresh.svfs") + vfs = SQLiteVFS(db, compress=True) + try: + packer = FolderPacker(vfs, fs_name="fresh") + with helpers.quiet(): + self.assertTrue(packer.pack_folder(self.src, incremental=True)) + finally: + vfs.close() + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(3, helpers.count_files(vfs)) + self.assertEqual("fresh", vfs.list_snapshots()[0]["name"]) + + def test_incremental_into_a_new_snapshot_keeps_history(self): + """派生新快照后再增量:旧版本原样保留,新版本只有变化的部分被重写。""" + vfs = SQLiteVFS(self.db, compress=True) + try: + parent = vfs._active_snapshot() + vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) + self.touch_later(os.path.join(self.src, "a.txt"), b"aaa-v2") + os.remove(os.path.join(self.src, "b.txt")) + self.assertTrue(self.pack_with(vfs, incremental=True)) + finally: + vfs.close() + self.assertEqual(1, self.stats["updated"]) + self.assertEqual(1, self.stats["removed"]) + + vfs = SQLiteVFS(self.db) + self.addCleanup(vfs.close) + self.assertEqual(2, len(vfs.list_snapshots())) + self.assertEqual(b"aaa-v2", vfs.read_file("a.txt")) + self.assertFalse(vfs.exists("b.txt")) + + vfs.use_snapshot(1) + self.assertEqual(b"aaa", vfs.read_file("a.txt"), "旧版本必须原样保留") + self.assertEqual(b"bbb", vfs.read_file("b.txt")) + + def test_incremental_does_not_touch_other_snapshots(self): + vfs = SQLiteVFS(self.db, compress=True) + try: + parent = vfs._active_snapshot() + vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) + os.remove(os.path.join(self.src, "b.txt")) + self.pack_with(vfs, incremental=True) + finally: + vfs.close() + + vfs = SQLiteVFS(self.db, snapshot_id=1) + self.addCleanup(vfs.close) + self.assertTrue(vfs.exists("b.txt"), "v1 不该被 v2 的增量影响") + + +class IncrementalCliTest(helpers.VFSTestCase): + def test_cli_incremental(self): + src = helpers.make_tree(self.path("src"), {"a.txt": b"aaa", "b.txt": b"bbb"}) + db = self.path("cli.svfs") + first = helpers.run_cli("sqlite_vfs.cli.packer", src, db, "--compress") + self.assertEqual(0, first.returncode, helpers.cli_output(first)) + + helpers.write_file(os.path.join(src, "new.txt"), b"new") + os.remove(os.path.join(src, "b.txt")) + second = helpers.run_cli("sqlite_vfs.cli.packer", src, db, "--incremental") + self.assertEqual(0, second.returncode, helpers.cli_output(second)) + self.assertIn("增量结果", second.stdout) + self.assertIn("新增 1", second.stdout) + self.assertIn("删除 1", second.stdout) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(b"new", vfs.read_file("new.txt")) + self.assertFalse(vfs.exists("b.txt")) + + def test_cli_snapshot_creates_a_new_version(self): + src = helpers.make_tree(self.path("src"), {"a.txt": b"aaa"}) + db = self.path("cli2.svfs") + helpers.run_cli("sqlite_vfs.cli.packer", src, db, "--compress") + + helpers.write_file(os.path.join(src, "a.txt"), b"aaa-v2") + result = helpers.run_cli( + "sqlite_vfs.cli.packer", src, db, "--snapshot", "v2", "--incremental" + ) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual([1, 2], [s["id"] for s in vfs.list_snapshots()]) + self.assertEqual(b"aaa-v2", vfs.read_file("a.txt")) + vfs.use_snapshot(1) + self.assertEqual(b"aaa", vfs.read_file("a.txt")) + + listing = helpers.run_cli("sqlite_vfs.cli.unpacker", db, self.path("out"), "--list-snapshots") + self.assertEqual(0, listing.returncode, helpers.cli_output(listing)) + self.assertIn("v2", listing.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integrity.py b/tests/test_integrity.py new file mode 100644 index 0000000..b6ef0f2 --- /dev/null +++ b/tests/test_integrity.py @@ -0,0 +1,324 @@ +"""完整性:每文件 sha256、快照级摘要、HMAC 签名。 + +设计上的一个重要区分: +* ``verify()`` 是**元数据级**校验(摘要、blob 是否存在、大小是否一致),很便宜; +* ``verify(check_content=True)`` 才会逐文件重算 sha256,是**权威**校验, + 代价是 O(总字节数)。同大小的内容篡改只有后者能发现。 +""" + +import json +import sqlite3 +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS, VFSIntegrityError + + +class DigestTest(helpers.VFSTestCase): + def _pack(self, name="d.svfs", tree=None): + src = helpers.make_tree( + self.path(f"src_{name}"), + tree or {"a.txt": b"aaa", "sub/b.txt": b"bbb"}, + ) + db = self.path(name) + helpers.pack(src, db, compress=True, name="digest-pack") + return db + + def test_digest_is_recorded_and_stable_across_reopen(self): + db = self._pack() + vfs = SQLiteVFS(db) + first = vfs.get_stats()["digest"] + self.assertTrue(first) + vfs.close() + + reopened = SQLiteVFS(db) + self.addCleanup(reopened.close) + self.assertEqual(first, reopened.get_stats()["digest"]) + + def test_digest_matches_manual_recomputation(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + stored = vfs.conn.execute("SELECT digest FROM snapshots WHERE id = 1").fetchone()[0] + self.assertEqual(stored, vfs.compute_digest(1, store=False)) + + def test_digest_changes_when_a_file_is_added(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + before = vfs.compute_digest(1) + + extra = helpers.write_file(self.path("extra.txt"), b"extra") + with helpers.quiet(): + vfs.add_file(extra, "extra.txt") + vfs.recount() + self.assertNotEqual(before, vfs.compute_digest(1)) + + def test_digest_changes_when_content_changes(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + before = vfs.compute_digest(1) + + vfs.remove("a.txt") + changed = helpers.write_file(self.path("changed.txt"), b"zzz") + with helpers.quiet(): + vfs.add_file(changed, "a.txt") + vfs.recount() + self.assertNotEqual(before, vfs.compute_digest(1)) + + def test_digest_changes_when_metadata_changes(self): + """路径/大小/时间戳/权限也参与摘要,不只是内容。""" + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + before = vfs.compute_digest(1) + + vfs.conn.execute("UPDATE files SET permissions = 511 WHERE path = 'a.txt'") + vfs.conn.commit() + self.assertNotEqual(before, vfs.compute_digest(1)) + + def test_digest_ignores_insertion_order(self): + """摘要必须只取决于内容与元数据,与插入顺序无关。 + + 用 seed_rows 造数据(时间戳为 NULL),这样两个库的元数据完全一致, + 唯一的差别就是插入顺序。 + """ + rows = [ + {"path": "a.txt", "content": b"a"}, + {"path": "b.txt", "content": b"b"}, + {"path": "c.txt", "content": b"c"}, + ] + digests = [] + for index, ordered in enumerate((list(rows), list(reversed(rows)))): + vfs = SQLiteVFS(self.path(f"order{index}.svfs")) + self.addCleanup(vfs.close) + helpers.seed_rows(vfs, ordered) + digests.append(vfs.compute_digest(1)) + self.assertEqual(digests[0], digests[1]) + + +class VerifyTest(helpers.VFSTestCase): + def _pack(self, name="v.svfs", compress=True): + src = helpers.make_tree( + self.path(f"src_{name}"), + {"a.txt": b"aaa", "big.txt": b"repeat me " * 300, "sub/b.txt": b"bbb"}, + ) + db = self.path(name) + helpers.pack(src, db, compress=compress, name="verify-pack") + return db + + def test_verify_passes_on_a_fresh_pack(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + report = vfs.verify() + self.assertTrue(report["ok"], report["errors"]) + self.assertTrue(report["digest_ok"]) + self.assertEqual(3, report["files_checked"]) + self.assertEqual(0, report["content_checked"], "默认不做逐文件哈希") + + def test_verify_with_content_check_passes(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + report = vfs.verify(check_content=True) + self.assertTrue(report["ok"], report["errors"]) + self.assertEqual(3, report["content_checked"]) + + def test_verify_reports_empty_database(self): + vfs = SQLiteVFS(":memory:") + self.addCleanup(vfs.close) + report = vfs.verify() + self.assertFalse(report["ok"]) + self.assertIn("没有", " ".join(report["errors"])) + + def test_content_tamper_is_caught_by_check_content(self): + """同大小改掉内容:元数据级校验看不出来,权威校验必须抓到。""" + db = self._pack(compress=False) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + vfs.conn.execute("UPDATE blobs SET content = X'626262' WHERE size = 3") + vfs.conn.commit() + + # 元数据级:摘要与大小都没变,所以看不出来 + self.assertTrue(vfs.verify()["ok"]) + + report = vfs.verify(check_content=True) + self.assertFalse(report["ok"]) + self.assertTrue(any("内容哈希不匹配" in e for e in report["errors"]), report) + + def test_read_file_with_verify_hash_raises_on_tamper(self): + db = self._pack(compress=False) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.conn.execute("UPDATE blobs SET content = X'626262' WHERE size = 3") + vfs.conn.commit() + + self.assertEqual(b"bbb", vfs.read_file("a.txt")) # 不校验时照旧返回 + with self.assertRaises(VFSIntegrityError): + vfs.read_file("a.txt", verify_hash=True) + + def test_missing_blob_is_caught(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.conn.execute("DELETE FROM blobs WHERE hash = (SELECT content_hash FROM files WHERE path='a.txt')") + vfs.conn.commit() + + report = vfs.verify() + self.assertFalse(report["ok"]) + self.assertTrue(any("blob 缺失" in e for e in report["errors"]), report) + with self.assertRaises(VFSIntegrityError): + vfs.read_file("a.txt") + + def test_size_mismatch_is_caught(self): + db = self._pack(compress=False) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.conn.execute("UPDATE blobs SET size = size + 1") + vfs.conn.commit() + + report = vfs.verify() + self.assertFalse(report["ok"]) + self.assertTrue(any("大小不一致" in e for e in report["errors"]), report) + with self.assertRaises(VFSIntegrityError): + vfs.read_file("a.txt") + + def test_metadata_tamper_is_caught_by_digest(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.conn.execute("UPDATE files SET file_size = 999 WHERE path = 'a.txt'") + vfs.conn.commit() + + report = vfs.verify() + self.assertFalse(report["ok"]) + self.assertFalse(report["digest_ok"]) + self.assertTrue(any("摘要不一致" in e for e in report["errors"]), report) + + def test_path_tamper_is_caught_by_digest(self): + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.conn.execute("UPDATE files SET path = 'renamed.txt' WHERE path = 'a.txt'") + vfs.conn.commit() + self.assertFalse(vfs.verify()["digest_ok"]) + + def test_verify_can_target_an_older_snapshot(self): + """摘要防的是"绕过 API 的改动";走 API 的写入会刷新摘要,不算篡改。""" + db = self._pack() + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.create_snapshot("v2", copy_entries=True) + vfs.remove("a.txt") + vfs.recount() + + # 走 API 删文件之后 recount 会刷新摘要,所以两个快照此刻都是自洽的 + self.assertTrue(vfs.verify(snapshot_id=2)["ok"]) + self.assertTrue(vfs.verify(snapshot_id=1)["ok"]) + + # 绕过 API 直接改 v2 的元数据 → 只有 v2 报错 + vfs.conn.execute("UPDATE files SET file_size = 1 WHERE snapshot_id = 2") + vfs.conn.commit() + self.assertFalse(vfs.verify(snapshot_id=2)["digest_ok"]) + self.assertTrue( + vfs.verify(snapshot_id=1)["ok"], "v1 没被碰过,不该受影响" + ) + + +class SignatureTest(helpers.VFSTestCase): + def _pack(self, name="sig.svfs"): + src = helpers.make_tree(self.path("src"), {"a.txt": b"aaa", "b.txt": b"bbb"}) + db = self.path(name) + helpers.pack(src, db, compress=True, name="signed-pack") + return db + + def test_sign_and_verify(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + signature = vfs.sign("top-secret", key_id="release-1") + self.assertEqual(64, len(signature), "sha256 十六进制应当是 64 个字符") + + report = vfs.verify_signature("top-secret") + self.assertTrue(report["ok"], report["errors"]) + self.assertEqual("release-1", report["key_id"]) + self.assertEqual("hmac-sha256", vfs.list_signatures()[0]["algorithm"]) + + def test_wrong_key_fails(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + vfs.sign("right-key") + report = vfs.verify_signature("wrong-key") + self.assertFalse(report["ok"]) + self.assertTrue(report["errors"]) + + def test_tampering_after_signing_fails(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + vfs.sign("k") + + extra = helpers.write_file(self.path("evil.txt"), b"evil") + with helpers.quiet(): + vfs.add_file(extra, "evil.txt") + vfs.recount() + + self.assertFalse( + vfs.verify_signature("k")["ok"], "签名之后内容变了,验签必须失败" + ) + + def test_verify_without_a_signature_is_reported(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + report = vfs.verify_signature("k") + self.assertFalse(report["ok"]) + self.assertIn("没有签名记录", " ".join(report["errors"])) + + def test_verify_signature_by_key_id(self): + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + vfs.sign("k1", key_id="one") + vfs.sign("k2", key_id="two") + self.assertTrue(vfs.verify_signature("k1", key_id="one")["ok"]) + self.assertTrue(vfs.verify_signature("k2", key_id="two")["ok"]) + self.assertFalse(vfs.verify_signature("k1", key_id="two")["ok"]) + + def test_signature_verifies_on_a_readonly_connection(self): + """只读方必须能在不改动库的前提下完成验证。""" + db = self._pack() + vfs = SQLiteVFS(db) + vfs.sign("shared-secret", key_id="audit") + vfs.close() + + before = helpers.md5_of(db) + readonly = SQLiteVFS(db, readonly=True) + self.addCleanup(readonly.close) + + report = readonly.verify_signature("shared-secret") + self.assertTrue(report["ok"], report["errors"]) + self.assertTrue(readonly.verify(check_content=True)["ok"]) + self.assertEqual(before, helpers.md5_of(db), "只读验证改动了库文件") + + def test_sign_requires_write_access(self): + db = self._pack() + readonly = SQLiteVFS(db, readonly=True) + self.addCleanup(readonly.close) + with self.assertRaises(PermissionError): + readonly.sign("k") + + def test_digest_can_be_exported_for_external_signing(self): + """非对称签名要交给外部工具,所以摘要必须能干净地取出来。""" + vfs = SQLiteVFS(self._pack()) + self.addCleanup(vfs.close) + payload = { + "digest": vfs.compute_digest(1, store=False), + "snapshot_id": 1, + "algorithm": "sha256", + } + text = json.dumps(payload, sort_keys=True) + self.assertIn("digest", text) + self.assertEqual(64, len(payload["digest"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..2759d6a --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,327 @@ +"""v1 / v2 → v3 的格式迁移。 + +迁移是"只读旧库、重建新库、原子替换":中间态写在旁边的临时文件里, +所以任何一步失败都不会让原库变成半成品;默认还会先留一份 .bak。 +""" + +import os +import sqlite3 +import unittest +import zlib + +import helpers + +from sqlite_vfs import migration +from sqlite_vfs.core import SQLiteVFS + + +class DetectVersionTest(helpers.VFSTestCase): + def test_detects_each_format(self): + v1 = helpers.make_old_schema_db(self.path("v1.svfs")) + v2 = helpers.make_v2_schema_db( + self.path("v2.svfs"), rows=[{"path": "a.txt", "content": b"a"}] + ) + v3 = self.path("v3.svfs") + SQLiteVFS(v3).close() + + self.assertEqual(1, migration.detect_schema_version(v1)) + self.assertEqual(2, migration.detect_schema_version(v2)) + self.assertEqual(3, migration.detect_schema_version(v3)) + + def test_detects_non_vfs_database(self): + path = self.path("plain.sqlite") + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE unrelated (id INTEGER)") + conn.commit() + conn.close() + self.assertIsNone(migration.detect_schema_version(path)) + + def test_missing_file_raises(self): + with self.assertRaises(FileNotFoundError): + migration.detect_schema_version(self.path("nope.svfs")) + + +class MigrateFromV2Test(helpers.VFSTestCase): + PAYLOAD = b"repeat me please. " * 100 + + def _make_v2(self, name="v2.svfs"): + rows = [ + {"path": "plain.txt", "content": b"plain v2 content"}, + {"path": "dup1.txt", "content": self.PAYLOAD}, + {"path": "dup2.txt", "content": self.PAYLOAD}, + { + "path": "compressed.txt", + "content": zlib.compress(self.PAYLOAD), + "compress_algo": "zlib", + "file_size": len(self.PAYLOAD), + }, + {"path": "empty.txt", "content": b""}, + {"path": "sub", "is_directory": True}, + {"path": "sub/nested.txt", "content": b"nested"}, + ] + return helpers.make_v2_schema_db(self.path(name), rows=rows) + + def test_content_survives_byte_for_byte(self): + db = self._make_v2() + result = migration.migrate(db) + self.assertTrue(result["migrated"]) + self.assertEqual(2, result["from_version"]) + self.assertEqual(3, migration.detect_schema_version(db)) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(b"plain v2 content", vfs.read_file("plain.txt")) + self.assertEqual(self.PAYLOAD, vfs.read_file("dup1.txt")) + self.assertEqual(self.PAYLOAD, vfs.read_file("dup2.txt")) + self.assertEqual( + self.PAYLOAD, + vfs.read_file("compressed.txt"), + "旧库里 zlib 压缩过的内容必须解压后原样返回", + ) + self.assertEqual(b"", vfs.read_file("empty.txt")) + self.assertEqual(b"nested", vfs.read_file("sub/nested.txt")) + + def test_structure_is_preserved(self): + db = self._make_v2() + migration.migrate(db) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + self.assertTrue(vfs.is_dir("sub")) + self.assertEqual( + ["compressed.txt", "dup1.txt", "dup2.txt", "empty.txt", "plain.txt", "sub"], + sorted(r["path"] for r in vfs.list_directory("/")), + ) + self.assertEqual(6, helpers.count_files(vfs)) + + def test_identical_content_is_deduplicated_during_migration(self): + db = self._make_v2() + result = migration.migrate(db) + # dup1 / dup2 / compressed.txt 三份内容相同 → 只留一份 blob + self.assertEqual(4, result["blobs"], "plain/empty/nested/payload 共四份") + self.assertGreater(result["dedup_saved"], 0, "应当报告去重省下的字节数") + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(4, helpers.blob_count(vfs)) + + def test_backup_is_created_and_original_untouched(self): + db = self._make_v2() + before = helpers.md5_of(db) + result = migration.migrate(db) + + self.assertTrue(result["backup"]) + self.assertTrue(os.path.exists(result["backup"])) + self.assertEqual( + before, helpers.md5_of(result["backup"]), "备份必须与迁移前的库完全一致" + ) + self.assertEqual(2, migration.detect_schema_version(result["backup"])) + self.assertNotEqual(before, helpers.md5_of(db)) + + def test_backup_can_be_disabled(self): + db = self._make_v2() + result = migration.migrate(db, backup=False) + self.assertIsNone(result["backup"]) + self.assertFalse(os.path.exists(db + ".bak")) + + def test_second_migration_is_a_no_op(self): + db = self._make_v2() + migration.migrate(db) + digest_before = helpers.md5_of(db) + + again = migration.migrate(db) + self.assertFalse(again["migrated"]) + self.assertIn("已经是", again["reason"]) + self.assertEqual(digest_before, helpers.md5_of(db), "重复迁移不该改动库") + + def test_migrated_pack_verifies_and_signs(self): + db = self._make_v2() + migration.migrate(db) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + report = vfs.verify(check_content=True) + self.assertTrue(report["ok"], report["errors"]) + self.assertEqual(6, report["files_checked"]) + self.assertEqual(6, report["content_checked"]) + self.assertTrue(vfs.sign("k") and vfs.verify_signature("k")["ok"]) + + def test_metadata_is_carried_over(self): + db = self.path("meta.svfs") + conn = sqlite3.connect( + helpers.make_v2_schema_db( + db, rows=[{"path": "a.txt", "content": b"aaa"}] + ) + ) + conn.execute( + "UPDATE files SET modified_time = '2020-01-02 03:04:05', permissions = 493," + " created_time = '2020-01-01 00:00:00' WHERE path = 'a.txt'" + ) + conn.commit() + conn.close() + + migration.migrate(db) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + info = vfs.get_file_info("a.txt") + self.assertEqual(0o755, info["permissions"]) + self.assertEqual(2020, info["modified_time"].year) + self.assertEqual(3, info["modified_time"].hour) + + def test_snapshot_name_defaults_to_the_pack_name(self): + db = self._make_v2() + migration.migrate(db) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual("v2pack", vfs.list_snapshots()[0]["name"]) + + def test_custom_snapshot_name(self): + db = self._make_v2() + migration.migrate(db, snapshot_name="imported") + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual("imported", vfs.list_snapshots()[0]["name"]) + + def test_migrate_to_a_new_file_leaves_the_original_alone(self): + """原库在只读介质上时不能原地替换,所以支持输出到新路径。""" + db = self._make_v2() + before = helpers.md5_of(db) + target = self.path("migrated.svfs") + + result = migration.migrate(db, output=target) + self.assertTrue(result["migrated"]) + self.assertFalse(result["in_place"]) + self.assertEqual(os.path.abspath(target), result["output"]) + self.assertIsNone(result["backup"], "有独立输出时不必再备份") + + self.assertEqual(before, helpers.md5_of(db), "原库必须一个字节都没变") + self.assertEqual(2, migration.detect_schema_version(db)) + self.assertEqual(3, migration.detect_schema_version(target)) + + vfs = SQLiteVFS(target) + self.addCleanup(vfs.close) + self.assertEqual(self.PAYLOAD, vfs.read_file("dup1.txt")) + self.assertTrue(vfs.verify(check_content=True)["ok"]) + + def test_migration_of_a_readonly_file_into_a_new_file(self): + db = self._make_v2() + os.chmod(db, 0o444) + try: + result = migration.migrate(db, output=self.path("out.svfs")) + self.assertTrue(result["migrated"]) + finally: + os.chmod(db, 0o644) + + def test_refuses_to_overwrite_an_existing_output(self): + db = self._make_v2() + target = helpers.write_file(self.path("taken.svfs"), b"occupied") + with self.assertRaises(FileExistsError): + migration.migrate(db, output=target) + self.assertEqual(b"occupied", helpers.read_bytes(target)) + + def test_backup_can_be_forced_with_output(self): + db = self._make_v2() + result = migration.migrate( + db, output=self.path("m2.svfs"), backup=True + ) + self.assertTrue(os.path.exists(result["backup"])) + + +class MigrateFromV1Test(helpers.VFSTestCase): + def test_v1_bool_flag_is_mapped_to_zlib(self): + payload = b"legacy payload " * 50 + db = helpers.make_old_schema_db( + self.path("v1.svfs"), + extra_rows=[ + { + "path": "packed.txt", + "content": zlib.compress(payload), + "compressed": True, + "file_size": len(payload), + }, + {"path": "stored.txt", "content": b"stored raw"}, + ], + ) + result = migration.migrate(db) + self.assertEqual(1, result["from_version"]) + + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + self.assertEqual(payload, vfs.read_file("packed.txt")) + self.assertEqual(b"stored raw", vfs.read_file("stored.txt")) + self.assertEqual(b"hello", vfs.read_file("legacy.txt")) + self.assertEqual("zlib", vfs.get_blob_info("packed.txt")["compress_algo"]) + self.assertEqual("none", vfs.get_blob_info("stored.txt")["compress_algo"]) + self.assertTrue(vfs.verify(check_content=True)["ok"]) + + +class MigrateCliTest(helpers.VFSTestCase): + """迁移后的库要能被 CLI 正常解包。""" + + def test_migrated_database_unpacks_via_cli(self): + payload = b"migrated content " * 20 + src = helpers.make_tree(self.path("src"), {"a.txt": payload, "sub/b.txt": b"b"}) + db = self.path("v2.svfs") + helpers.pack(src, db, compress=True) + + # 把库降级成 v2 形态:内容搬回 files 表,去掉 blobs/snapshots + conn = sqlite3.connect(db) + conn.executescript( + """ + ALTER TABLE files ADD COLUMN content BLOB; + ALTER TABLE files ADD COLUMN freq_blob BLOB; + ALTER TABLE files ADD COLUMN compress_algo TEXT DEFAULT 'none'; + UPDATE files + SET content = (SELECT b.content FROM blobs b WHERE b.hash = files.content_hash), + freq_blob = (SELECT b.freq_blob FROM blobs b WHERE b.hash = files.content_hash), + compress_algo = (SELECT b.compress_algo FROM blobs b WHERE b.hash = files.content_hash); + DROP TABLE blobs; + DROP TABLE snapshots; + DROP TABLE signatures; + """ + ) + conn.commit() + conn.close() + self.assertEqual(2, migration.detect_schema_version(db)) + + out = self.path("out") + failed = helpers.run_cli("sqlite_vfs.cli.unpacker", db, out) + self.assertNotEqual(0, failed.returncode, "v2 库应当被拒绝并提示迁移") + + self.assertTrue(migration.migrate(db)["migrated"]) + result = helpers.run_cli("sqlite_vfs.cli.unpacker", db, out) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) + + +class MigrateInvalidInputTest(helpers.VFSTestCase): + def test_non_vfs_database_is_rejected(self): + path = self.path("plain.sqlite") + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE unrelated (id INTEGER)") + conn.commit() + conn.close() + with self.assertRaises(ValueError): + migration.migrate(path) + + def test_missing_file_raises(self): + with self.assertRaises(FileNotFoundError): + migration.migrate(self.path("nope.svfs")) + + def test_original_survives_a_failure(self): + """迁移失败时原库必须原样保留(中间态写在临时文件里)。""" + path = self.path("plain.sqlite") + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE unrelated (id INTEGER)") + conn.commit() + conn.close() + before = helpers.md5_of(path) + + with self.assertRaises(ValueError): + migration.migrate(path) + self.assertEqual(before, helpers.md5_of(path)) + self.assertFalse(os.path.exists(path + migration.TEMP_SUFFIX)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 0000000..511ce67 --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,561 @@ +"""按内容寻址发布:导出对象 + 清单,客户端按清单取回并校验。 + +这里刻意用**真实的 HTTP 服务器**跑一遍(而不是只读本地文件), +因为这条路线的卖点正是"普通的静态托管 / CDN 就能用", +顺带也能量出"取一个文件到底要几次请求、多少字节"。 +""" + +import functools +import hashlib +import http.server +import json +import os +import shutil +import threading +import time +import unittest + +import helpers + +from sqlite_vfs import publish +from sqlite_vfs.core import SQLiteVFS + +import zlib + + +class CountingHandler(http.server.SimpleHTTPRequestHandler): + """记录请求次数与字节数的静态文件服务。""" + + requests = None # 由测试注入的列表 + + def log_message(self, *args): # 安静点 + pass + + def do_GET(self): + CountingHandler.requests.append(self.path) + super().do_GET() + + def send_head(self): + path = self.translate_path(self.path) + if os.path.isdir(path): + # 不列目录,避免噪音 + self.send_error(404, "no listing") + return None + return super().send_head() + + +class HttpServerFixture(helpers.VFSTestCase): + def serve(self, directory): + CountingHandler.requests = [] + handler = functools.partial(CountingHandler, directory=directory) + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + + def serve(): + try: + httpd.serve_forever() + except OSError: + # 关停时 select 作用在已关闭的 socket 上(Windows 上的收尾竞态), + # 与被测代码无关,静音以免掩盖真实错误 + pass + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + self.addCleanup(httpd.shutdown) + self.addCleanup(httpd.server_close) + host, port = httpd.server_address[:2] + return f"http://{host}:{port}" + + +class ExportTest(helpers.VFSTestCase): + def _pack(self, tree=None, compress=True, name="p.svfs"): + src = helpers.make_tree( + self.path("src"), + tree + or { + "index.html": b"
", + "a.txt": b"same-content", + "dup.txt": b"same-content", + "sub/b.js": b"console.log('hello');\n" * 40, + "empty_dir": None, + }, + ) + db = self.path(name) + helpers.pack(src, db, compress=compress, name="demo-pack") + return src, db + + def test_objects_are_deduplicated(self): + _src, db = self._pack() + out = self.path("objects") + result = publish.export_objects(db, out) + self.assertEqual(4, result["files"], "4 个文件") + self.assertEqual( + 3, result["objects"], "a.txt 与 dup.txt 内容相同 → 只导出 3 个对象" + ) + self.assertGreater(result["dedup_saved"], 0) + + def test_object_content_is_the_original_bytes(self): + """库里内容是压缩的,导出必须是原始字节——消费方不该需要我们的解压代码。""" + _src, db = self._pack(compress=True) + out = self.path("objects") + publish.export_objects(db, out) + + vfs = SQLiteVFS(db, readonly=True) + try: + self.assertEqual("zlib", vfs.get_blob_info("sub/b.js")["compress_algo"]) + digest = vfs.get_blob_info("sub/b.js")["hash"] + finally: + vfs.close() + + expected = b"console.log('hello');\n" * 40 + blob = os.path.join(out, "blobs", digest[:2], digest) + with open(blob, "rb") as f: + self.assertEqual(expected, f.read()) + self.assertEqual(digest, hashlib.sha256(expected).hexdigest()) + + def test_object_name_is_its_hash(self): + _src, db = self._pack() + out = self.path("objects") + publish.export_objects(db, out) + blob_root = os.path.join(out, "blobs") + found = 0 + for root, _dirs, files in os.walk(blob_root): + for name in files: + with open(os.path.join(root, name), "rb") as f: + self.assertEqual( + name, hashlib.sha256(f.read()).hexdigest(), f"{name} 名字与内容不符" + ) + found += 1 + self.assertEqual(3, found) + + def test_savings_are_reported_in_separate_units(self): + """去重与压缩必须分开报:把"压缩省下的"算进"去重省下的"会误导选型。""" + _src, db = self._pack() + out = self.path("objects") + result = publish.export_objects(db, out) + manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME)) + + vfs = SQLiteVFS(db, readonly=True) + try: + blobs = vfs.list_blobs() + finally: + vfs.close() + unique = sum(b["size"] for b in blobs) + stored = sum(b["stored_size"] for b in blobs) + + self.assertEqual(unique, result["unique_bytes"]) + self.assertEqual(stored, result["stored_bytes"]) + self.assertEqual( + unique, result["objects_bytes"], "导出的对象正好是去重后的原始字节" + ) + self.assertEqual( + max(0, manifest["stats"]["total_size"] - unique), result["dedup_saved"] + ) + self.assertEqual(max(0, unique - stored), result["compression_saved"]) + # 两个口径加起来才是"相对原始文件总量省下的全部" + self.assertLessEqual( + result["dedup_saved"] + result["compression_saved"], + manifest["stats"]["total_size"], + ) + + def test_manifest_contents(self): + _src, db = self._pack() + out = self.path("objects") + publish.export_objects(db, out) + manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME)) + + self.assertEqual(publish.MANIFEST_FORMAT, manifest["format"]) + self.assertEqual(publish.MANIFEST_VERSION, manifest["version"]) + self.assertEqual("demo-pack", manifest["name"]) + self.assertEqual(1, manifest["snapshot"]["id"]) + self.assertEqual(64, len(manifest["snapshot"]["digest"])) + + entries = manifest["entries"] + self.assertTrue(entries["empty_dir"]["dir"], "空目录也要出现在清单里") + self.assertEqual("text/javascript", entries["sub/b.js"]["mime"]) + self.assertEqual(len(b"console.log('hello');\n" * 40), entries["sub/b.js"]["size"]) + self.assertEqual(entries["a.txt"]["hash"], entries["dup.txt"]["hash"]) + self.assertEqual( + {"files": 4, "directories": 2}, + {k: manifest["stats"][k] for k in ("files", "directories")}, + ) + + def test_manifest_is_deterministic_for_the_same_snapshot(self): + """清单里不放"生成时间",所以同一快照导出两次得到完全一样的清单(可 diff、可签名)。""" + _src, db = self._pack() + first = self.path("out1") + second = self.path("out2") + publish.export_objects(db, first) + publish.export_objects(db, second) + self.assertEqual( + helpers.read_bytes(os.path.join(first, publish.MANIFEST_NAME)), + helpers.read_bytes(os.path.join(second, publish.MANIFEST_NAME)), + ) + + def test_publish_is_read_only_on_the_pack(self): + _src, db = self._pack() + before = helpers.md5_of(db) + publish.export_objects(db, self.path("objects")) + self.assertEqual(before, helpers.md5_of(db)) + + def test_refuses_non_empty_output_without_overwrite(self): + _src, db = self._pack() + out = self.path("objects") + publish.export_objects(db, out) + with self.assertRaises(FileExistsError): + publish.export_objects(db, out) + publish.export_objects(db, out, overwrite=True) + + def test_shard_zero_puts_objects_flat(self): + _src, db = self._pack() + out = self.path("objects") + publish.export_objects(db, out, shard=0) + names = os.listdir(os.path.join(out, "blobs")) + self.assertEqual(3, len(names)) + self.assertTrue(all(len(n) == 64 for n in names)) + + def test_publish_a_specific_snapshot(self): + src, db = self._pack(tree={"a.txt": b"v1"}) + vfs = SQLiteVFS(db, compress=True) + try: + vfs.create_snapshot("v2", copy_entries=True) + vfs.remove("a.txt") + with helpers.quiet(): + vfs.add_file(helpers.write_file(self.path("a2.txt"), b"v2"), "a.txt") + vfs.recount() + finally: + vfs.close() + + out = self.path("objects") + publish.export_objects(db, out, snapshot_id=1) + manifest = publish.load_manifest(os.path.join(out, publish.MANIFEST_NAME)) + self.assertEqual(1, manifest["snapshot"]["id"]) + digest = manifest["entries"]["a.txt"]["hash"] + with open(os.path.join(out, "blobs", digest[:2], digest), "rb") as f: + self.assertEqual(b"v1", f.read()) + + def test_manifest_of_a_nonexistent_snapshot_is_rejected(self): + _src, db = self._pack() + with self.assertRaises(FileNotFoundError): + publish.export_objects(db, self.path("objects"), snapshot_id=99) + + +class FetchLocalTest(helpers.VFSTestCase): + def _export(self, tree=None): + src = helpers.make_tree( + self.path("src"), + tree + or { + "index.html": b"
", + "a.txt": b"same-content", + "dup.txt": b"same-content", + "sub/b.js": b"console.log(1)", + "empty_dir": None, + }, + ) + db = self.path("p.svfs") + helpers.pack(src, db, compress=True) + out = self.path("objects") + publish.export_objects(db, out) + return src, out + + def _fetch(self, objects, target, cache=None, **kwargs): + manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME)) + fetcher = publish.ObjectFetcher( + manifest, local_root=objects, cache_dir=cache, **kwargs + ) + return fetcher, fetcher.fetch(target, paths=kwargs.pop("paths", None)) + + def test_roundtrip_restores_the_tree(self): + src, objects = self._export() + target = self.path("restored") + _fetcher, stats = self._fetch(objects, target) + + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target)) + self.assertEqual(4, stats.files) + self.assertEqual(2, stats.directories, "empty_dir 与 sub 都要建出来") + self.assertEqual(3, stats.objects_downloaded, "一个对象只取一次(两个路径共用)") + + def test_mtime_and_mode_are_preserved(self): + src, objects = self._export() + target = self.path("restored") + self._fetch(objects, target) + for name in ("a.txt", "sub/b.js"): + with self.subTest(name=name): + source = os.path.join(src, *name.split("/")) + restored = os.path.join(target, *name.split("/")) + self.assertAlmostEqual( + os.path.getmtime(source), os.path.getmtime(restored), delta=2 + ) + + def test_partial_fetch_by_file_and_by_prefix(self): + _src, objects = self._export() + manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME)) + + fetcher = publish.ObjectFetcher(manifest, local_root=objects) + self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub/b.js"])) + self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub"])) + self.assertEqual({"sub/b.js"}, fetcher.resolve(["sub/"])) + self.assertEqual( + {"a.txt", "sub/b.js"}, + fetcher.resolve(["a.txt", "sub"]), + "没被选中的同级文件不该被带进来", + ) + self.assertEqual( + {"index.html", "a.txt", "dup.txt", "sub/b.js"}, + fetcher.resolve(None), + "不指定 --path 就是全部文件(不含目录)", + ) + with self.assertRaises(FileNotFoundError): + fetcher.resolve(["nope.txt"]) + + def test_partial_fetch_writes_only_selected(self): + _src, objects = self._export() + target = self.path("partial") + manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME)) + stats = publish.ObjectFetcher(manifest, local_root=objects).fetch( + target, paths=["sub"] + ) + self.assertEqual(1, stats.files) + self.assertEqual( + {"sub/b.js"}, {k for k, v in helpers.read_tree(target).items() if v is not None} + ) + + def test_cache_is_reused_and_verified(self): + _src, objects = self._export() + cache = self.path("cache") + _fetcher, first = self._fetch(objects, self.path("r1"), cache=cache) + self.assertEqual(3, first.objects_downloaded) + self.assertEqual(0, first.objects_from_cache) + + _fetcher, second = self._fetch(objects, self.path("r2"), cache=cache) + self.assertEqual(0, second.objects_downloaded, "第二次应当全部命中缓存") + self.assertEqual(3, second.objects_from_cache) + self.assertEqual(3, second.verified) + + def test_corrupted_cache_entry_is_healed(self): + _src, objects = self._export() + cache = self.path("cache") + self._fetch(objects, self.path("r1"), cache=cache) + + # 把缓存里的某个对象改坏 + blob_root = os.path.join(cache, "blobs") + victim = None + for root, _dirs, files in os.walk(blob_root): + for name in files: + victim = os.path.join(root, name) + with open(victim, "wb") as f: + f.write(b"corrupted!!") + + _fetcher, stats = self._fetch(objects, self.path("r2"), cache=cache) + self.assertEqual(1, stats.objects_downloaded, "坏掉的缓存应当被重新下载") + self.assertEqual(2, stats.objects_from_cache) + + def test_link_mode_hardlinks_into_the_cache(self): + if os.name == "nt" and not hasattr(os, "link"): + self.skipTest("平台不支持硬链接") + _src, objects = self._export() + cache = self.path("cache") + target = self.path("linked") + manifest = publish.load_manifest(os.path.join(objects, publish.MANIFEST_NAME)) + publish.ObjectFetcher( + manifest, local_root=objects, cache_dir=cache, link=True + ).fetch(target) + linked = os.path.join(target, "a.txt") + digest = hashlib.sha256(b"same-content").hexdigest() + cached = os.path.join(cache, "blobs", digest[:2], digest) + self.assertEqual( + os.stat(cached).st_ino, os.stat(linked).st_ino, "应当共用同一个 inode" + ) + + +class FetchOverHttpTest(HttpServerFixture): + def _export_and_serve(self): + src = helpers.make_tree( + self.path("src"), + { + "index.html": b"
", + "big.bin": b"x" * (256 * 1024), + "sub/b.js": b"console.log(1)", + }, + ) + db = self.path("p.svfs") + helpers.pack(src, db, compress=True) + objects = self.path("objects") + publish.export_objects(db, objects) + return src, objects, self.serve(objects), db + + def test_fetch_from_a_real_http_server(self): + src, objects, base, db = self._export_and_serve() + manifest_url = f"{base}/manifest.json" + target = self.path("restored") + + manifest = publish.load_manifest(manifest_url) + fetcher = publish.ObjectFetcher( + manifest, base_url=base, cache_dir=self.path("cache") + ) + started = time.time() + stats = fetcher.fetch(target) + elapsed = time.time() - started + + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target)) + self.assertEqual(3, stats.files) + self.assertEqual(3, stats.objects_downloaded) + + requests = CountingHandler.requests + manifest_bytes = os.path.getsize(os.path.join(objects, publish.MANIFEST_NAME)) + total_bytes = os.path.getsize(db) + print( + f"\n [HTTP 实测] 请求 {len(requests)} 次" + f"(清单 1 + 对象 {stats.objects_downloaded})" + f",下载 {stats.bytes_downloaded / 1024:.1f} KB" + f"(清单 {manifest_bytes / 1024:.1f} KB)" + f",耗时 {elapsed * 1000:.0f}ms" + ) + self.assertEqual( + 1 + stats.objects_downloaded, + len(requests), + "总请求数应当是 1 次清单 + 每个对象一次", + ) + self.assertLess(manifest_bytes, total_bytes, "清单必须远小于整包") + + def test_missing_object_is_reported_clearly(self): + _src, objects, base, _db = self._export_and_serve() + manifest = publish.load_manifest(f"{base}/manifest.json") + # 删掉一个对象,模拟"CDN 上少传了一个文件" + digest = manifest["entries"]["sub/b.js"]["hash"] + os.remove(os.path.join(objects, "blobs", digest[:2], digest)) + + fetcher = publish.ObjectFetcher(manifest, base_url=base) + with self.assertRaises(Exception): + fetcher.fetch(self.path("restored")) + + def test_tampered_object_over_http_is_rejected(self): + _src, objects, base, _db = self._export_and_serve() + manifest = publish.load_manifest(f"{base}/manifest.json") + digest = manifest["entries"]["sub/b.js"]["hash"] + victim = os.path.join(objects, "blobs", digest[:2], digest) + with open(victim, "wb") as f: + f.write(b"console.log(2)") # 同样长度,但内容不同 + + fetcher = publish.ObjectFetcher(manifest, base_url=base, verify=True) + with self.assertRaises(ValueError) as ctx: + fetcher.fetch(self.path("restored")) + self.assertIn("校验失败", str(ctx.exception)) + + # 关掉校验就会放过(默认必须是开的) + manifest2 = publish.load_manifest(f"{base}/manifest.json") + fetcher2 = publish.ObjectFetcher(manifest2, base_url=base, verify=False) + fetcher2.fetch(self.path("unverified")) + with open(os.path.join(self.path("unverified"), "sub", "b.js"), "rb") as f: + self.assertEqual(b"console.log(2)", f.read()) + + def test_manifest_from_url_mismatch_is_reported(self): + _src, objects, base, _db = self._export_and_serve() + with self.assertRaises(Exception): + publish.load_manifest(f"{base}/nope.json") + + def test_bad_manifest_format_is_rejected(self): + bad = helpers.write_file( + self.path("bad.json"), json.dumps({"format": "something-else"}).encode() + ) + with self.assertRaises(ValueError) as ctx: + publish.load_manifest(bad) + self.assertIn("svfs-objects", str(ctx.exception)) + + old = helpers.write_file( + self.path("old.json"), + json.dumps( + {"format": publish.MANIFEST_FORMAT, "version": 999} + ).encode(), + ) + with self.assertRaises(ValueError): + publish.load_manifest(old) + + +class PublishCliTest(helpers.VFSTestCase): + PUBLISH = "sqlite_vfs.cli.publish" + FETCH = "sqlite_vfs.cli.fetch" + + def _pack(self): + src = helpers.make_tree( + self.path("src"), {"a.txt": b"aaa", "sub/b.txt": b"bbb"} + ) + db = self.path("p.svfs") + helpers.run_cli("sqlite_vfs.cli.packer", src, db, "--compress") + return src, db + + def _fetch(self, manifest, target, *extra): + """CLI 默认用的是用户级缓存目录,测试必须换成临时目录才可复现。""" + return helpers.run_cli( + self.FETCH, + manifest, + target, + "--cache", + self.path("cache"), + *extra, + ) + + def test_publish_and_fetch_via_cli(self): + src, db = self._pack() + objects = self.path("objects") + published = helpers.run_cli(self.PUBLISH, db, objects) + self.assertEqual(0, published.returncode, helpers.cli_output(published)) + self.assertIn("发布完成", published.stdout) + self.assertTrue(os.path.exists(os.path.join(objects, "manifest.json"))) + + target = self.path("restored") + fetched = self._fetch(os.path.join(objects, "manifest.json"), target) + self.assertEqual(0, fetched.returncode, helpers.cli_output(fetched)) + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(target)) + + def test_publish_missing_db_exits_nonzero(self): + result = helpers.run_cli(self.PUBLISH, self.path("nope.svfs"), self.path("o")) + self.assertNotEqual(0, result.returncode) + self.assertNotIn("Traceback", helpers.cli_output(result)) + + def test_publish_to_nonempty_dir_requires_overwrite(self): + _src, db = self._pack() + objects = self.path("objects") + helpers.run_cli(self.PUBLISH, db, objects) + again = helpers.run_cli(self.PUBLISH, db, objects) + self.assertNotEqual(0, again.returncode) + self.assertIn("错误", again.stdout) + forced = helpers.run_cli(self.PUBLISH, db, objects, "--overwrite") + self.assertEqual(0, forced.returncode, helpers.cli_output(forced)) + + def test_manifest_only_mode(self): + _src, db = self._pack() + objects = self.path("objects") + result = helpers.run_cli(self.PUBLISH, db, objects, "--no-objects") + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + self.assertTrue(os.path.exists(os.path.join(objects, "manifest.json"))) + self.assertFalse(os.path.exists(os.path.join(objects, "blobs"))) + + def test_fetch_json_output(self): + _src, db = self._pack() + objects = self.path("objects") + helpers.run_cli(self.PUBLISH, db, objects) + result = self._fetch( + os.path.join(objects, "manifest.json"), self.path("restored"), "--json" + ) + self.assertEqual(0, result.returncode, helpers.cli_output(result)) + stats = json.loads(result.stdout) + self.assertEqual(2, stats["files"]) + self.assertEqual(2, stats["objects_downloaded"]) + + def test_fetch_missing_path_exits_nonzero(self): + _src, db = self._pack() + objects = self.path("objects") + helpers.run_cli(self.PUBLISH, db, objects) + result = self._fetch( + os.path.join(objects, "manifest.json"), + self.path("restored"), + "--path", + "nope.txt", + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("清单里没有", helpers.cli_output(result)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100644 index 0000000..fed7744 --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,153 @@ +"""端到端回归测试:打包 → 解包,必须逐字节无损。 + +这是本项目最核心的验收测试:只要它绿了,说明"把文件夹装进一个 .svfs 再拿出来" +这条主链路是通的。 +""" + +import os +import random +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS + + +def build_spec(): + """构造一个覆盖面较广的目录树。 + + 刻意包含:嵌套目录、空目录、空文件、压缩阈值两侧的样本、 + 可压缩与不可压缩内容、中文路径、带空格的路径、 + 以及含 % 和 _ 的文件名(这两个字符在 SQL LIKE 里是通配符)。 + """ + rnd = random.Random(20240913) + return { + "top.txt": b"top level\n", + "empty.txt": b"", + "one_byte.bin": b"\x00", + "below_threshold.bin": rnd.randbytes(999), + "compressible.txt": b"hello world, this is a highly compressible payload. " * 200, + "random.bin": rnd.randbytes(4096), + "empty_dir": None, + "sub/deep/deeper/nested.txt": b"nested\n", + "sub/space name.txt": b"spaces\n", + "sub/中文名.txt": "中文内容,用来验证非 ASCII 路径与内容。\n".encode("utf-8"), + "sub/percent%name_.txt": b"wildcards\n", + "doc/a.txt": b"a\n", + "docs/b.txt": b"b\n", + } + + +class RoundTripTest(helpers.VFSTestCase): + def _pack(self, compress): + src = helpers.make_tree(self.path("src"), build_spec()) + db = helpers.pack(src, self.path("out.svfs"), compress=compress, name="roundtrip") + return src, db + + def _assert_lossless(self, src, db, ignore_timestamps=False): + out = self.path("out") + with helpers.quiet(): + helpers.unpack(db, out, ignore_timestamps=ignore_timestamps) + # 以磁盘上的源目录为基准:build_spec 里没显式写出的中间目录 + # (sub、sub/deep 等)也必须在导出结果里出现 + helpers.assert_tree_equal(self, helpers.read_tree(src), helpers.read_tree(out)) + + def test_roundtrip_without_compression(self): + src, db = self._pack(compress=False) + self._assert_lossless(src, db) + + def test_roundtrip_with_compression(self): + src, db = self._pack(compress=True) + self._assert_lossless(src, db) + + def test_roundtrip_with_ignore_timestamps(self): + src, db = self._pack(compress=True) + self._assert_lossless(src, db, ignore_timestamps=True) + + def test_every_source_file_is_stored(self): + """P0-1:打包后库里必须有和源目录一一对应的文件记录。""" + src, db = self._pack(compress=True) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + expected = {rel for rel, content in build_spec().items() if content is not None} + stored = set(helpers.stored_files(vfs)) + self.assertEqual( + "无差异", + helpers.diff_trees({p: None for p in expected}, {p: None for p in stored}), + ) + + def test_metadata_counts_match_database(self): + """P1-5:上报的统计数字必须来自真实入库的数据,而不是遍历计数。""" + src, db = self._pack(compress=True) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + stats = vfs.get_stats() + stored_count = helpers.count_files(vfs) + source_count = len( + [rel for rel, content in build_spec().items() if content is not None] + ) + + self.assertEqual(source_count, stored_count, "入库文件数 ≠ 源文件数") + self.assertEqual( + source_count, + stats["total_files"], + "元数据里的文件数与实际入库数不一致", + ) + # total_size 记录的是原始文件大小,压缩后 content 里存的是压缩数据, + # 所以这里要和源文件大小比,而不是库里 blob 的长度 + source_size = sum( + len(content) for content in build_spec().values() if content is not None + ) + self.assertEqual(source_size, stats["total_size"]) + + def test_compression_reduces_database_size(self): + """用一个足够大的重复文本,让压缩效果超过 SQLite 页粒度(4KB)的噪声。 + + build_spec 里的小文件即使压得动,省下的字节也可能被页对齐吃掉, + 所以这里单独造一份 92KB 的样本。 + """ + payload = b"hello world, this is a highly compressible payload. " * 2000 + src = helpers.make_tree(self.path("big_src"), {"big.txt": payload}) + plain_db = helpers.pack(src, self.path("plain.svfs"), compress=False) + compressed_db = helpers.pack(src, self.path("compressed.svfs"), compress=True) + + plain_size = os.path.getsize(plain_db) + compressed_size = os.path.getsize(compressed_db) + self.assertLess( + compressed_size, + plain_size, + f"压缩后数据库应当更小: plain={plain_size}, compressed={compressed_size}", + ) + + def test_stored_blob_of_compressible_file_is_smaller(self): + """压缩不只是"标记了一下":库里真正存的字节必须变少。""" + src, db = self._pack(compress=True) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + + blob = vfs.get_blob_info("compressible.txt") + self.assertEqual("zlib", blob["compress_algo"]) + self.assertIsNone(blob["freq_blob"], "zlib 不需要频率表") + with open(os.path.join(src, "compressible.txt"), "rb") as f: + original = f.read() + self.assertEqual(len(original), blob["size"], "blob 记录的应当是原始大小") + # 高度重复的文本,zlib 至少能压掉一半(huffman 只能压到 ~56%) + self.assertLess(blob["stored_size"], len(original) // 2) + self.assertEqual(original, vfs.read_file("compressible.txt")) + + def test_packed_database_contains_no_duplicate_paths(self): + """唯一性现在是"每个快照内"的:UNIQUE(snapshot_id, path)。""" + src, db = self._pack(compress=False) + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + snapshot_id = vfs._active_snapshot() + total = vfs.conn.execute( + "SELECT COUNT(*) AS c FROM files WHERE snapshot_id = ?", (snapshot_id,) + ).fetchone()["c"] + self.assertEqual(total, len(helpers.all_paths(vfs))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..1009d6f --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,308 @@ +"""快照与时间旅行。 + +一个库里可以存同一棵树的历史版本:``files`` 表的唯一约束从 ``path`` +变成 ``UNIQUE(snapshot_id, path)``,所以同一路径在不同快照里各有一行。 +""" + +import os +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS + + +class SnapshotLifecycleTest(helpers.VFSTestCase): + def _vfs(self, **kwargs): + vfs = SQLiteVFS(self.path("s.svfs"), **kwargs) + self.addCleanup(vfs.close) + return vfs + + def test_first_write_creates_a_default_snapshot(self): + vfs = self._vfs() + self.assertIsNone(vfs.snapshot_id) + src = helpers.write_file(self.path("a.txt"), b"a") + with helpers.quiet(): + vfs.add_file(src, "a.txt") + + self.assertIsNotNone(vfs.snapshot_id) + snapshots = vfs.list_snapshots() + self.assertEqual(1, len(snapshots)) + self.assertEqual(vfs.snapshot_id, snapshots[0]["id"]) + + def test_add_vfs_metadata_names_the_default_snapshot(self): + vfs = self._vfs() + vfs.add_vfs_metadata("my-pack") + self.assertEqual("my-pack", vfs.list_snapshots()[0]["name"]) + + def test_readonly_never_creates_a_snapshot(self): + """只读打开不能因为"读一下"就写库——否则只读挂载会失败。""" + db = self.path("ro.svfs") + vfs = SQLiteVFS(db) + vfs.close() + before = helpers.md5_of(db) + + readonly = SQLiteVFS(db, readonly=True) + self.addCleanup(readonly.close) + self.assertIsNone(readonly._active_snapshot()) + self.assertEqual([], readonly.list_directory("/")) + self.assertIsNone(readonly.get_file_info("anything")) + self.assertEqual(0, readonly.get_stats()["total_files"]) + self.assertEqual(before, helpers.md5_of(db), "只读会话改动了库文件") + + def test_create_snapshot_becomes_active_and_is_listed(self): + vfs = self._vfs() + src = helpers.write_file(self.path("a.txt"), b"a") + with helpers.quiet(): + vfs.add_file(src, "a.txt") + first = vfs.snapshot_id + + second = vfs.create_snapshot("v2") + self.assertNotEqual(first, second) + self.assertEqual(second, vfs.snapshot_id, "新建的快照应当成为当前快照") + self.assertEqual(["v2"], [s["name"] for s in vfs.list_snapshots() if s["id"] == second]) + self.assertEqual(0, helpers.count_files(vfs), "新快照本身是空的") + + def test_use_snapshot_switches_the_view(self): + vfs = self._vfs() + src = helpers.write_file(self.path("a.txt"), b"a") + with helpers.quiet(): + vfs.add_file(src, "a.txt") + first = vfs.snapshot_id + vfs.create_snapshot("v2") + + vfs.use_snapshot(first) + self.assertEqual(first, vfs.snapshot_id) + self.assertTrue(vfs.exists("a.txt")) + + def test_use_missing_snapshot_raises(self): + vfs = self._vfs() + with self.assertRaises(FileNotFoundError): + vfs.use_snapshot(999) + + def test_constructor_releases_the_connection_when_snapshot_is_invalid(self): + """构造期抛异常也要释放连接,否则 Windows 上库文件被锁住删不掉。 + + 注意:POSIX 上删除已打开的文件总是成功,所以这条断言主要是在 Windows 上生效 + (那正是它被发现的地方)。 + """ + db = self.path("locked.svfs") + SQLiteVFS(db).close() # 先建出一个合法的库 + with self.assertRaises(FileNotFoundError): + SQLiteVFS(db, snapshot_id=999) + + os.remove(db) # 句柄没释放的话这里会 PermissionError + self.assertFalse(os.path.exists(db)) + + def test_reopen_defaults_to_the_latest_snapshot(self): + db = self.path("multi.svfs") + vfs = SQLiteVFS(db) + src = helpers.write_file(self.path("a.txt"), b"a") + with helpers.quiet(): + vfs.add_file(src, "a.txt") + vfs.create_snapshot("v2") + vfs.close() + + reopened = SQLiteVFS(db) + self.addCleanup(reopened.close) + self.assertEqual(2, reopened.get_stats()["snapshot_id"]) + self.assertEqual("v2", reopened.get_stats()["snapshot_name"]) + + def test_delete_snapshot_removes_entries_and_signature_rows(self): + vfs = self._vfs() + src = helpers.write_file(self.path("a.txt"), b"a") + with helpers.quiet(): + vfs.add_file(src, "a.txt") + vfs.create_snapshot("v2") + vfs.sign("k") + self.assertEqual(1, len(vfs.list_signatures(2))) + + deleted = vfs.delete_snapshot(2) + self.assertEqual(0, deleted, "v2 是空快照,没有条目被删") + self.assertEqual([1], [s["id"] for s in vfs.list_snapshots()]) + self.assertIsNone(vfs.snapshot_id, "当前快照被删后应当需要重新解析") + + +class TimeTravelTest(helpers.VFSTestCase): + def _pack_v1_then_v2(self): + """构造两个版本的库:v1 有 a.txt=first,v2 里 a.txt=second 且多了 b.txt。""" + db = self.path("hist.svfs") + vfs = SQLiteVFS(db) + self.addCleanup(vfs.close) + vfs.add_vfs_metadata("history") + + first = helpers.write_file(self.path("a1.txt"), b"first") + with helpers.quiet(): + vfs.add_file(first, "a.txt") + vfs.recount() + + vfs.create_snapshot("v2", copy_entries=True) + vfs.remove("a.txt") + second = helpers.write_file(self.path("a2.txt"), b"second") + extra = helpers.write_file(self.path("b.txt"), b"b") + with helpers.quiet(): + vfs.add_file(second, "a.txt") + vfs.add_file(extra, "b.txt") + vfs.recount() + return vfs + + def test_same_path_has_different_content_per_snapshot(self): + vfs = self._pack_v1_then_v2() + self.assertEqual(b"second", vfs.read_file("a.txt")) + + vfs.use_snapshot(1) + self.assertEqual(b"first", vfs.read_file("a.txt")) + + def test_new_files_are_invisible_in_the_older_snapshot(self): + vfs = self._pack_v1_then_v2() + self.assertTrue(vfs.exists("b.txt")) + vfs.use_snapshot(1) + self.assertFalse(vfs.exists("b.txt")) + + def test_copy_entries_forks_from_the_parent(self): + vfs = self._pack_v1_then_v2() + vfs.use_snapshot(1) + self.assertEqual({b"first"}, {vfs.read_file("a.txt")}) + self.assertEqual({"a.txt"}, helpers.all_paths(vfs)) + + def test_blobs_are_shared_between_snapshots(self): + vfs = self._pack_v1_then_v2() + # first / second / b 三份内容 + self.assertEqual(3, helpers.blob_count(vfs)) + + def test_each_snapshot_has_its_own_digest(self): + vfs = self._pack_v1_then_v2() + digests = {s["id"]: s["digest"] for s in vfs.list_snapshots()} + self.assertEqual(2, len(digests)) + self.assertTrue(all(digests.values()), "每个快照都应当有摘要") + self.assertNotEqual(digests[1], digests[2], "内容不同,摘要必须不同") + + def test_counts_are_per_snapshot(self): + vfs = self._pack_v1_then_v2() + self.assertEqual(2, vfs.get_stats()["total_files"]) + vfs.use_snapshot(1) + self.assertEqual(1, vfs.get_stats()["total_files"]) + + def test_export_respects_the_active_snapshot(self): + vfs = self._pack_v1_then_v2() + out_v2 = self.path("out_v2") + with helpers.quiet(): + vfs.export_filesystem(out_v2) + + vfs.use_snapshot(1) + out_v1 = self.path("out_v1") + with helpers.quiet(): + vfs.export_filesystem(out_v1) + + self.assertEqual( + {"a.txt": b"first"}, {k: v for k, v in helpers.read_tree(out_v1).items()} + ) + self.assertEqual( + {"a.txt": b"second", "b.txt": b"b"}, + {k: v for k, v in helpers.read_tree(out_v2).items()}, + ) + + def test_list_directory_is_snapshot_scoped(self): + vfs = self._pack_v1_then_v2() + self.assertEqual( + ["a.txt", "b.txt"], sorted(r["path"] for r in vfs.list_directory("/")) + ) + vfs.use_snapshot(1) + self.assertEqual( + ["a.txt"], sorted(r["path"] for r in vfs.list_directory("/")) + ) + + def test_remove_in_one_snapshot_does_not_affect_the_other(self): + vfs = self._pack_v1_then_v2() + vfs.remove("a.txt") + self.assertFalse(vfs.exists("a.txt")) + vfs.use_snapshot(1) + self.assertEqual(b"first", vfs.read_file("a.txt")) + + def test_mkdir_in_one_snapshot_does_not_leak(self): + vfs = self._pack_v1_then_v2() + vfs.mk_dir("only-here") + self.assertTrue(vfs.is_dir("only-here")) + vfs.use_snapshot(1) + self.assertFalse(vfs.exists("only-here")) + + def test_snapshot_id_can_be_chosen_at_construction(self): + vfs = self._pack_v1_then_v2() + db = vfs.db_path + first = SQLiteVFS(db, snapshot_id=1) + self.addCleanup(first.close) + self.assertEqual(b"first", first.read_file("a.txt")) + self.assertEqual(1, first.get_stats()["snapshot_id"]) + + +class UnpackerSnapshotTest(helpers.VFSTestCase): + def _two_versions(self, db_name="u.svfs"): + """v1 有 one.txt,v2(派生自 v1)删掉 one.txt 并加上 two.txt。""" + db = self.path(db_name) + vfs = SQLiteVFS(db) + vfs.add_vfs_metadata("u") + one = helpers.write_file(self.path("one.txt"), b"one") + with helpers.quiet(): + vfs.add_file(one, "one.txt") + vfs.recount() + vfs.create_snapshot("v2", copy_entries=True) + vfs.remove("one.txt") + two = helpers.write_file(self.path("two.txt"), b"two") + with helpers.quiet(): + vfs.add_file(two, "two.txt") + vfs.recount() + return vfs + + def test_unpacker_list_root_is_snapshot_scoped(self): + from sqlite_vfs.folder_unpacker import FolderUnpacker + + vfs = self._two_versions() + self.addCleanup(vfs.close) + + self.assertEqual( + ["two.txt"], [r["path"] for r in FolderUnpacker(vfs).list_root()] + ) + vfs.use_snapshot(1) + self.assertEqual( + ["one.txt"], [r["path"] for r in FolderUnpacker(vfs).list_root()] + ) + + def test_unpacker_export_follows_the_snapshot(self): + from sqlite_vfs.folder_unpacker import FolderUnpacker + + vfs = self._two_versions("u2.svfs") + self.addCleanup(vfs.close) + + out = self.path("out") + with helpers.quiet(): + FolderUnpacker(vfs).unpack_filesystem(out) + self.assertEqual({"two.txt": b"two"}, helpers.read_tree(out)) + + vfs.use_snapshot(1) + out1 = self.path("out1") + with helpers.quiet(): + FolderUnpacker(vfs).unpack_filesystem(out1) + self.assertEqual({"one.txt": b"one"}, helpers.read_tree(out1)) + + def test_unpacker_check_database_counts_only_the_active_snapshot(self): + from sqlite_vfs.folder_unpacker import FolderUnpacker + + vfs = self._two_versions("u3.svfs") + self.addCleanup(vfs.close) + + # v2 只有一个条目(two.txt),v1 也只有一个(one.txt); + # 两个快照加起来是 2 条,所以"报 1"就证明统计是分快照的 + ok, message = FolderUnpacker(vfs).check_database() + self.assertTrue(ok, message) + self.assertIn("快照 2", message) + self.assertIn("1 个文件/目录", message) + + vfs.use_snapshot(1) + ok, message = FolderUnpacker(vfs).check_database() + self.assertTrue(ok, message) + self.assertIn("快照 1", message) + self.assertIn("1 个文件/目录", message) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index c44507b..ff85f46 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,144 @@ resolution-markers = [ "python_full_version < '3.8'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.5.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/97/41ccb6acac36fdd13592a686a21b311418f786f519e5794b957afbcea938/annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802", size = 13504, upload-time = "2023-05-31T19:09:57.294Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/f0/a2ee543a96cc624c35a9086f39b1ed2aa403c6d355dfe47a11ee5c64a164/annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd", size = 11136, upload-time = "2023-05-31T19:09:56.141Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "3.7.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.8'" }, + { name = "idna", version = "3.10", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "sniffio", marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/99/2dfd53fd55ce9838e6ff2d4dac20ce58263798bd1a0dbe18b3a9af3fcfce/anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", size = 142927, upload-time = "2023-07-05T16:45:02.294Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/24/44299477fe7dcc9cb58d0a57d5a7588d6af2ff403fdd2d47a246c91a3246/anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5", size = 80896, upload-time = "2023-07-05T16:44:59.805Z" }, +] + +[[package]] +name = "anyio" +version = "4.5.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.8.*'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "sniffio", marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "idna", version = "3.11", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -97,43 +235,31 @@ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/97/c783634659c292 wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/5b/f1523dd545f92f7df468e5f653ffa4df30ac222f3c884e51e139878f1cb5/cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", size = 425932, upload-time = "2024-09-04T20:44:49.491Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/93/7e547ab4105969cc8c93b38a667b82a835dd2cc78f3a7dad6130cfd41e1d/cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", size = 448585, upload-time = "2024-09-04T20:44:51.671Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/c4/a308f2c332006206bb511de219efeff090e9d63529ba0a77aae72e82248b/cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", size = 456268, upload-time = "2024-09-04T20:44:53.51Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/5b/b63681518265f2f4060d2b60755c1c77ec89e5e045fc3773b72735ddaad5/cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", size = 436592, upload-time = "2024-09-04T20:44:55.085Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/19/b51af9f4a4faa4a8ac5a0e5d5c2522dcd9703d07fac69da34a36c4d960d3/cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", size = 446512, upload-time = "2024-09-04T20:44:57.135Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, @@ -155,50 +281,36 @@ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/56/b1ba7935a17738 wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, @@ -386,10 +498,8 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, @@ -397,10 +507,8 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, @@ -408,10 +516,8 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, @@ -447,6 +553,94 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/11/a8/c6a4b901d17399c77cd81fb001ce8961e9f5e04d3daf27e8925cb012e163/docutils-0.22.3-py3-none-any.whl", hash = "sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb", size = 633032, upload-time = "2025-11-06T02:35:52.391Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.103.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "anyio", version = "3.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "pydantic", version = "2.5.3", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "starlette", version = "0.27.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/25/bde6d83e92c05deb6ffc320dbe29f9f9e3f83803b3a4e2e47e48153e8c44/fastapi-0.103.2.tar.gz", hash = "sha256:75a11f6bfb8fc4d2bec0bd710c2d5f2829659c0e8c0afd5560fdda6ce25ec653", size = 11231158, upload-time = "2023-09-28T20:05:52.685Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/d2/3ad038a2365fefbac19d9a046cab7ce45f4c7bfa81d877cbece9707de9ce/fastapi-0.103.2-py3-none-any.whl", hash = "sha256:3270de872f0fe9ec809d4bd3d4d890c6d5cc7b9611d721d6438f9dacc8c4ef2e", size = 66338, upload-time = "2023-09-28T20:05:50.621Z" }, +] + +[[package]] +name = "fastapi" +version = "0.124.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "annotated-doc", version = "0.0.4", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "starlette", version = "0.44.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/21/ade3ff6745a82ea8ad88552b4139d27941549e4f19125879f848ac8f3c3d/fastapi-0.124.4.tar.gz", hash = "sha256:0e9422e8d6b797515f33f500309f6e1c98ee4e85563ba0f2debb282df6343763", size = 378460, upload-time = "2025-12-12T15:00:43.891Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/57/aa70121b5008f44031be645a61a7c4abc24e0e888ad3fc8fda916f4d188e/fastapi-0.124.4-py3-none-any.whl", hash = "sha256:6d1e703698443ccb89e50abe4893f3c84d9d6689c0cf1ca4fad6d3c15cf69f15", size = 113281, upload-time = "2025-12-12T15:00:42.44Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "annotated-doc", version = "0.0.5", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pydantic", version = "2.13.5", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "annotated-doc", version = "0.0.5", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.13.5", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-inspection", version = "0.4.4", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "id" version = "1.5.0" @@ -883,6 +1077,427 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pydantic" +version = "2.5.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "annotated-types", version = "0.5.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "importlib-metadata", version = "6.7.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "pydantic-core", version = "2.14.6", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/3f/56142232152145ecbee663d70a19a45d078180633321efb3847d2562b490/pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a", size = 651797, upload-time = "2023-12-22T10:39:46.736Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/b7/9aea7ee6c01fe3f3c03b8ca3c7797c866df5fecece9d6cb27caa138db2e2/pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4", size = 381926, upload-time = "2023-12-22T10:39:43.758Z" }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "annotated-types", version = "0.7.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "annotated-types", version = "0.7.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "annotated-types", version = "0.8.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic-core", version = "2.46.5", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-inspection", version = "0.4.4", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.14.6" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/7d/8304d8471cfe4288f95a3065ebda56f9790d087edc356ad5bd83c89e2d79/pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948", size = 360305, upload-time = "2023-12-21T19:56:33.986Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/fc/bfb0da2b2d5b44e49c4c0ce99b07bbfd9f1a4dc92fd3e328a5cf1144467e/pydantic_core-2.14.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9", size = 1864856, upload-time = "2023-12-21T19:52:15.42Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/3a/46913f3134aff44d11edd7bdbba88efe6081f963014e6eaccf83fd8de2d7/pydantic_core-2.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c", size = 1744162, upload-time = "2023-12-21T19:52:17.997Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/2d/919d3642da44bc9d9c60a2e7bbda04633fc3ffbd6768c355ac0d7e2424d7/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4", size = 1832506, upload-time = "2023-12-21T19:52:20.422Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/cd/a2db754b0124e64ad7912160d9c9db310cbd52a990841ef121b53453992d/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534", size = 1855925, upload-time = "2023-12-21T19:52:22.463Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/5c/63eb74c7a97daf0ee45dc876f0b0d9cdea9c5c9d64e92508a765cb802e14/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08", size = 2005859, upload-time = "2023-12-21T19:52:25.127Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/0c/3aeafa496aaf656be3682cbcacbfe3b4a4b366aaddac0ea74fb2c7c276a2/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d", size = 2987131, upload-time = "2023-12-21T19:52:27.874Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/28/3c6843e6b203999be2660d3f114be196f2182dcac533dc764ad320c9184d/pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245", size = 2072940, upload-time = "2023-12-21T19:52:29.905Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/7e/f1c1cf229bd404f5daf972345030f0c205424a326e67ae888c4a5a9066bd/pydantic_core-2.14.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c", size = 1922278, upload-time = "2023-12-21T19:52:31.836Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/32/a2f381c8ae08a9682d4e7943ba1f5b518e6f2bdd8261c23721691b332966/pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66", size = 2012961, upload-time = "2023-12-21T19:52:34.561Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/c5/2accf5bbc145b890454d4eaf8dcd6423d406fc9f64147fd9020618363866/pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590", size = 2138726, upload-time = "2023-12-21T19:52:36.463Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/70/9e4f5624c6d62fe4e0c3199c818c141ab37756987e1e6db965b18d9c344b/pydantic_core-2.14.6-cp310-none-win32.whl", hash = "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7", size = 1731407, upload-time = "2023-12-21T19:52:38.92Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4b/26/0645f87ed58c9ec41def2268ea1d96d4a436bbcd151fe126b80cb649e49d/pydantic_core-2.14.6-cp310-none-win_amd64.whl", hash = "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87", size = 1891831, upload-time = "2023-12-21T19:52:41.027Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/77/cbfa02b5f46c5ec6be131d97ae93eef883e25d61b4f4d0a058c792b7e3a2/pydantic_core-2.14.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4", size = 1861976, upload-time = "2023-12-21T19:52:42.943Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/17/3e4908cf8cb5a1d189f9dfa7cb5698d945e9a4db6b9138e3fef3c32c1f68/pydantic_core-2.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421", size = 1744008, upload-time = "2023-12-21T19:52:45.088Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/53/7844d20be3a334ea46cdcde8a480cf47e31026d4117d7415a0144d7379c9/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b", size = 1830087, upload-time = "2023-12-21T19:52:47.868Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f1/7b/0fd3444362f31c5f42b655c1ed734480433aa9f8bde97daa19cee0bc2844/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e", size = 1852928, upload-time = "2023-12-21T19:52:50.681Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/1d/601f861c0d76154217ea6b066e39f04159a761b9c3a7ca56b0dd0267ce3a/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96", size = 2003799, upload-time = "2023-12-21T19:52:52.661Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/5e/a30d56bb6b19e84bcde76cba2d6df45779f127ec73fa2e6d91f0ad3d4bc2/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b", size = 2985579, upload-time = "2023-12-21T19:52:55.195Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/84/2dc88180fc6f0d13aab2a47a53b89c2dbc239e2a87d0a58e31077e111e82/pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1", size = 2071737, upload-time = "2023-12-21T19:52:57.548Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/18/7c17d33b2c8dea2189b2547bafcb70a69a3e537eec12429cc0abfedab683/pydantic_core-2.14.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937", size = 1919929, upload-time = "2023-12-21T19:52:59.911Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/7b/a1cfe9d3fdedf2b33d41960500c17ccba025b483720c79965b73d607687f/pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622", size = 2010042, upload-time = "2023-12-21T19:53:02.556Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/09/c39be628d6068952f30b381576a4392af2024505747572cd70b19f6d9bde/pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2", size = 2135569, upload-time = "2023-12-21T19:53:04.926Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/07/ea362b25882fb0efbe2818011a572a112416903fbc3205b6c5dab3d9695c/pydantic_core-2.14.6-cp311-none-win32.whl", hash = "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2", size = 1730779, upload-time = "2023-12-21T19:53:06.842Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/d2/fcb3bc3d6d2fa35387b57e9925f1ff5469c2da634b85061dadbd8c398545/pydantic_core-2.14.6-cp311-none-win_amd64.whl", hash = "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23", size = 1891297, upload-time = "2023-12-21T19:53:09.412Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/68/41661007a1436f5f3dea7b9f536f083bbf843c8ebd6a207c36c98b01bde1/pydantic_core-2.14.6-cp311-none-win_arm64.whl", hash = "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6", size = 1849591, upload-time = "2023-12-21T19:53:11.84Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/26/4bd7ac215215322a693c178a022993450ebf7b1e91b26941f72407e1e9a1/pydantic_core-2.14.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec", size = 1855038, upload-time = "2023-12-21T19:53:14.05Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/f8/07a2563f40b863ba97f3db648697f3f1d7b7edf1bd679f210064cb556e74/pydantic_core-2.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7", size = 1718829, upload-time = "2023-12-21T19:53:16.61Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/98/fb42628ed811643c364e05353d3a015c74859402994420aeba8e3e34a54c/pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51", size = 1824883, upload-time = "2023-12-21T19:53:18.652Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/ed/6a318d3846ac45e4e8d7c81a4c4f9cad341f4715521cc2cc7baecd6be9c0/pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f", size = 1834955, upload-time = "2023-12-21T19:53:20.879Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d0/adf341fb8ed080bf5abb91c42752ffa099d8439e45d3fa40a21f259f724c/pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a", size = 1994990, upload-time = "2023-12-21T19:53:23.309Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/58/7cac843607f3b2d0af1768fae90ef219413db163a7cfb7557344edfeed2f/pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af", size = 3001843, upload-time = "2023-12-21T19:53:25.501Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/62/076e6c43735950e911d80c6edf215314a8cf9b8adefe9613b72b09ccb1ee/pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b", size = 2067417, upload-time = "2023-12-21T19:53:27.652Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/21/7ca5edf46bc6706152d459b560d669cfd72afe0dda24292408f1be8008d6/pydantic_core-2.14.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd", size = 1922514, upload-time = "2023-12-21T19:53:30.443Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/53/101aac1d63a743284cdae804ceb6f561879c385f355caf20d2d87da6d36d/pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91", size = 2004496, upload-time = "2023-12-21T19:53:32.411Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/7a/2e906fc1a5e4ca45e730118f0afb4878a39a1d505d895835d8cc5452446c/pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c", size = 2129106, upload-time = "2023-12-21T19:53:34.501Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/3e/28bd56c6aeadaae2eca12e39274b880505dd7fc9fa7b732d11167275c084/pydantic_core-2.14.6-cp312-none-win32.whl", hash = "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786", size = 1743090, upload-time = "2023-12-21T19:53:36.711Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/13/afa2b5c336d30a43592f9bc8d5769ccd15b32b4ef243bd792496fe336925/pydantic_core-2.14.6-cp312-none-win_amd64.whl", hash = "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40", size = 1866497, upload-time = "2023-12-21T19:53:39.401Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/33/68e91365ac5ef23fc70fbc4e24ab2f212a6ca39cd23b81589af9807946df/pydantic_core-2.14.6-cp312-none-win_arm64.whl", hash = "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e", size = 1844384, upload-time = "2023-12-21T19:53:41.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/36/5202bd359fa46cc9a1a33bda770097b406f9d5d3771bb991c665d1ed0b24/pydantic_core-2.14.6-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0", size = 1865689, upload-time = "2023-12-21T19:53:43.508Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/31/58342e325be50d24a8201e3973e4c652db921c717f7c152e20beb4ffa6fc/pydantic_core-2.14.6-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c", size = 1733541, upload-time = "2023-12-21T19:53:45.743Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/c0/cd9456b07fc057119132564176a0a91b9bcc918cb826b8252b7d31122fd1/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed", size = 1833672, upload-time = "2023-12-21T19:53:47.837Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/a6/af8e5d076b6336c1d81716fd43956fe17501cf69b2afb9a17c09be6309bb/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a", size = 1857284, upload-time = "2023-12-21T19:53:50.302Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/b1/42b9d9fddd5bbe483c1c84f376cbee7778d192086e71e9db506dea5965e2/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c", size = 2006188, upload-time = "2023-12-21T19:53:52.403Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/44/e590ba7e3b14f2cc5442309a44d05f06c53f5beeab152846ea5804575d57/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb", size = 2942523, upload-time = "2023-12-21T19:53:54.979Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/29/7c8ffcf7e736c7a6345f83657a34ea7cf710bf39be4452632f35c09b1905/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06", size = 2060504, upload-time = "2023-12-21T19:53:57.272Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/ab/14f074ca58d994b4a552864d935cbf3949d6beb8b4b76f8bbee218a9087c/pydantic_core-2.14.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8", size = 1919242, upload-time = "2023-12-21T19:53:59.169Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/d0/a4f7847083b5fe46bb17e8bc02779a433ac62985dc26ddbf35dcb8f0307f/pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8", size = 2014090, upload-time = "2023-12-21T19:54:02.707Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/54/ddbc2dbc3bbe75956d19a67e0dd4d85baddcbca1377fd8954f0446286500/pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e", size = 2140516, upload-time = "2023-12-21T19:54:05.411Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/41/1d11547d817e949659947386360a3c77afbb93cab86ffefc0a38f8b581f2/pydantic_core-2.14.6-cp37-none-win32.whl", hash = "sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6", size = 1731790, upload-time = "2023-12-21T19:54:07.561Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/9f/bca85affee28fff3d8fd848643f6c7bbcbd0567c07f2fb2e5c8e5690f6e8/pydantic_core-2.14.6-cp37-none-win_amd64.whl", hash = "sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391", size = 1893193, upload-time = "2023-12-21T19:54:09.599Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/2a/8cff567680c0d5e03ef4da218656a61286add825b4733476e6ba13ffeee9/pydantic_core-2.14.6-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149", size = 1864405, upload-time = "2023-12-21T19:54:12.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/39/cf14a183949bf162ab13a327b2f3a0f757e610f9c378a850e195d71bcfa0/pydantic_core-2.14.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b", size = 1731310, upload-time = "2023-12-21T19:54:14.199Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/4b/da4ed701ee2ff392916f19149f8fb6d705282d96971cbf256142d0c11594/pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb", size = 1831776, upload-time = "2023-12-21T19:54:16.553Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/10/dc849eb0c1890c99958d3ae2cfacb502e4d0ab0360c63c7a20231ea04b32/pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7", size = 1855995, upload-time = "2023-12-21T19:54:19.059Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/ea/435b1ad6890eec709e49dbcc5c0a72ca62ff8c6e62cfc45b7386e5e4cecc/pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8", size = 2005517, upload-time = "2023-12-21T19:54:21.248Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/3d/f4739255d8676debf398116e8ded523cf9bc9289a14734b3dc10645da67d/pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42", size = 2941205, upload-time = "2023-12-21T19:54:23.311Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/3d/1a5936fc5558521e8aae22dfb7f0ae6b649040b5fcef7f25be1371d02752/pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80", size = 2058974, upload-time = "2023-12-21T19:54:26.203Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/5c/289261738045fa6b97e75d8c2ee110fab5c2d1025f7d345816f0f56f1c1e/pydantic_core-2.14.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d", size = 1922529, upload-time = "2023-12-21T19:54:28.48Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/d1/a291cef89adaa3d82b89055a010bd60560a7bda798e2e729d3dfeb875236/pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1", size = 2013197, upload-time = "2023-12-21T19:54:31.422Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/8a/d2c7668e15d3be9157e8328712db22568770640fdcc3a13f4ff0cdd87ee9/pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60", size = 2138823, upload-time = "2023-12-21T19:54:33.59Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/cc/207e04b69e0c891f33b83f95fd37d9376ca4ffaf29887c1279617e955229/pydantic_core-2.14.6-cp38-none-win32.whl", hash = "sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe", size = 1731248, upload-time = "2023-12-21T19:54:36.503Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/43/413069afaae660a6f54e19af18e05783ef1316f9c3e0791632579b65d8d7/pydantic_core-2.14.6-cp38-none-win_amd64.whl", hash = "sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8", size = 1891828, upload-time = "2023-12-21T19:54:39.276Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/8c/d40937f7f7ccfe9776d1e32b36cebe606da9f11624927bd26722c43ea9cb/pydantic_core-2.14.6-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab", size = 1864545, upload-time = "2023-12-21T19:54:42.17Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/cd/252101e88458f4e7c4d2c44400050f92a0b13960ed3c489b513c97aaa7a6/pydantic_core-2.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f", size = 1731389, upload-time = "2023-12-21T19:54:44.353Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/1c/ab01fa05c9fc885a73357116c494feafe1207035f13848e4a772fc9d6154/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0", size = 1831538, upload-time = "2023-12-21T19:54:46.398Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/cf/d2e97b2bfd0bff7c4e9086fab03956003e906557c9c52941c15fed75152d/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b", size = 1855754, upload-time = "2023-12-21T19:54:48.673Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/57/9a77cc69f05f725a2b492a18209a43ba4e8b9ee179d3c27a8b6b3ab2f921/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160", size = 2005450, upload-time = "2023-12-21T19:54:51.464Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/ca/e8fe62da4eb4b538c380900372021c560c3514514677d6d328ac5b95da7c/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b", size = 2988683, upload-time = "2023-12-21T19:54:53.962Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/0f/45626f8bf7f7973320531bb384ac302eb9b05a70885b9db2bf1db4cf447b/pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab", size = 2072750, upload-time = "2023-12-21T19:54:56.089Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/95/d0bc7df3de0eaad08de467c50d1dc423839864f32e78da1cf57af3bbb2cc/pydantic_core-2.14.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0", size = 1922674, upload-time = "2023-12-21T19:54:58.347Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/09/8078e77e73dda7df0d5cca8541d1fb731a52bc00188806676c3635c344a9/pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9", size = 2013299, upload-time = "2023-12-21T19:55:00.875Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/ae/ec8eaa6d9a1305100321d7b9c3c87e015ae61da02a877cfd16b0366b18ff/pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411", size = 2138619, upload-time = "2023-12-21T19:55:05.131Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/23/d210c33379ef0525c2cf870a1922b89c11afc3a5b5b2e0485323a383c03a/pydantic_core-2.14.6-cp39-none-win32.whl", hash = "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975", size = 1731133, upload-time = "2023-12-21T19:55:08.945Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/88/a996678cc0a6ed714ccef36c155caacae9b072689c72f1a6f5e62ea62f5b/pydantic_core-2.14.6-cp39-none-win_amd64.whl", hash = "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9", size = 1891877, upload-time = "2023-12-21T19:55:11.41Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/1e/04ede6259a552777a859d2d5828aedd540ca0db967641d61be864a49671a/pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95", size = 1862248, upload-time = "2023-12-21T19:55:13.855Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/84/c53d351f926630753b8dcf37ec2edf8b55a5a1724b3edc5104e06d3e54f1/pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277", size = 1734357, upload-time = "2023-12-21T19:55:16.575Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/bb/58bd737b1f4a3b567410fd7a55f2e0ed4ba3209bb1a7a35856714a322a04/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670", size = 1821824, upload-time = "2023-12-21T19:55:18.643Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/7f/20ddc4eb15708cc6832c0cc2e398d0fa642aaf28d6ebcbcfb2d284ec6824/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e", size = 1956646, upload-time = "2023-12-21T19:55:20.924Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/33/9f761908fde3a6bb10ac865459a6931e53a2cde622782d243365e70981b7/pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d", size = 1907843, upload-time = "2023-12-21T19:55:24.015Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/e4/da29895abb136eea169944eb81f866d783255c4a6fd581c667c15743b171/pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d", size = 2002448, upload-time = "2023-12-21T19:55:26.496Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/21/32afbed9bfedf916dff87846e10ecd8711ba63c88cd6c9bcfc3297ef22ca/pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94", size = 2131708, upload-time = "2023-12-21T19:55:28.924Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/b1/bb98ca320ddc91734839ef12af4afe8ae9710a734a05850225ff1830e7e8/pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66", size = 1985502, upload-time = "2023-12-21T19:55:31.264Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/a2/814a1657ab6cd52585c3d8e6a6323063633b912e639f924bc993eca4bb31/pydantic_core-2.14.6-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2", size = 1863057, upload-time = "2023-12-21T19:55:33.786Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f3/e9/3fb82cf52207408b10c4d4fa0fbe38e0752c13d073d78278d85636d543f0/pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24", size = 1822874, upload-time = "2023-12-21T19:55:36.867Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/4c/e620e8f31376d840e8e606ed5c856390170ac01a8d69e1057bdbec64565d/pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd", size = 1957139, upload-time = "2023-12-21T19:55:39.38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/04/1f380c8dec71d265f7f83a0a66eeb76ce38c073dd94b58a2f1f53e7e76a6/pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8", size = 1909022, upload-time = "2023-12-21T19:55:41.93Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/d6/d85082bcb96ba4c5398fa4d4f609afed1e90aefb32bf925bd2463df53a0f/pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f", size = 2002841, upload-time = "2023-12-21T19:55:44.966Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/61/4863614d5953b5760ca251f8f937db66982bbef8fbdd8038ab199b840beb/pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a", size = 2132438, upload-time = "2023-12-21T19:55:47.847Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/0a/c56318f1668de782f31b6e9798217e2e5a99d4cce7a8eddffb60bebe3c09/pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda", size = 1862337, upload-time = "2023-12-21T19:55:50.819Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/64/de5432d19c42adbb26c4513866e2639c37c9081687c670bf8dc16cedfb6f/pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145", size = 1734547, upload-time = "2023-12-21T19:55:53.512Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/47/9f996e867123189f0b12364b00057887b61193d3d004a4391450e980512f/pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1", size = 1821630, upload-time = "2023-12-21T19:55:56.439Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/97/9e/f42db0e2931cd67bf990d22215ec50444e31aa6e80e63b8531ab1a5f3ffb/pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e", size = 1956611, upload-time = "2023-12-21T19:55:58.763Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/91/b5d718de2fc191a1937470e79b53535cf0c3a87b2f21ee927710f4dd4570/pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052", size = 1908319, upload-time = "2023-12-21T19:56:01.193Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e2/6d/789f2495c66c99a98b7a09a96145d5f3408941f839de7751995d9a5a8428/pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba", size = 2002542, upload-time = "2023-12-21T19:56:04.204Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b2/4a/3be721510f2fea9ce56b25812e6d6ecea9833c06fa8ae479cd41beb404f5/pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4", size = 2131778, upload-time = "2023-12-21T19:56:07.136Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/03/2393b702e4b13c0270c096837a9d28d133e4032d8bd382906190cee296d1/pydantic_core-2.14.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d", size = 1985802, upload-time = "2023-12-21T19:56:09.555Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/59/f6/1e7193769d24b32b19139fb875693d7a351af17f10354e7583a0f7b61a49/pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2", size = 1862312, upload-time = "2023-12-21T19:56:12.75Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/ff/812893fd262a98f0291f6afd87a530eb87c75ddc92034b938b8d15aa5ff4/pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03", size = 1734314, upload-time = "2023-12-21T19:56:15.351Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/7e/4af14122c7ea67ad5582fddae56f7827044f6b43cca6c7e7421686cca3de/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f", size = 1821778, upload-time = "2023-12-21T19:56:17.794Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/3d/91a26a7004a57f374d85d837b4b06dde818045ddba34bc19909e04e2a14d/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a", size = 1956642, upload-time = "2023-12-21T19:56:20.471Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/76/ee3c136138fbda5f58c3c49371503b42f3a9c678ef284a0b39be17253d78/pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf", size = 1907789, upload-time = "2023-12-21T19:56:23.676Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/7a/ceb3c9228ad9ff009ee70fd09ffb9160a45a8adaac5c9a90bc9496a1020e/pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556", size = 2002380, upload-time = "2023-12-21T19:56:26.159Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/b8/5baba04b116546302bc0a07ba0989326a167aeec29fd6f5cadc7deb758b1/pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341", size = 2131726, upload-time = "2023-12-21T19:56:28.883Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/54/0e231a3405827ad8cdc84ae043aa95bfa2cd2d712fc089cb5162dcbbb2e6/pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e", size = 1985447, upload-time = "2023-12-21T19:56:31.571Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938, upload-time = "2024-12-18T11:27:14.406Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684, upload-time = "2024-12-18T11:27:16.489Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169, upload-time = "2024-12-18T11:27:22.16Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227, upload-time = "2024-12-18T11:27:25.097Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695, upload-time = "2024-12-18T11:27:28.656Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662, upload-time = "2024-12-18T11:27:30.798Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370, upload-time = "2024-12-18T11:27:33.692Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813, upload-time = "2024-12-18T11:27:37.111Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287, upload-time = "2024-12-18T11:27:40.566Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414, upload-time = "2024-12-18T11:27:43.757Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301, upload-time = "2024-12-18T11:27:47.36Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685, upload-time = "2024-12-18T11:27:50.508Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876, upload-time = "2024-12-18T11:27:53.54Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/53/13e9917fc69c0a4aea06fd63ed6a8d6cda9cf140ca9584d49c1650b0ef5e/pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", size = 1899595, upload-time = "2024-12-18T11:29:40.887Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/20/26c549249769ed84877f862f7bb93f89a6ee08b4bee1ed8781616b7fbb5e/pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", size = 1775010, upload-time = "2024-12-18T11:29:44.823Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/eb/8234e05452d92d2b102ffa1b56d801c3567e628fdc63f02080fdfc68fd5e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", size = 1830727, upload-time = "2024-12-18T11:29:46.904Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/df/59f915c8b929d5f61e5a46accf748a87110ba145156f9326d1a7d28912b2/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", size = 1868393, upload-time = "2024-12-18T11:29:49.098Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/52/81cf4071dca654d485c277c581db368b0c95b2b883f4d7b736ab54f72ddf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", size = 2040300, upload-time = "2024-12-18T11:29:51.43Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/00/05197ce1614f5c08d7a06e1d39d5d8e704dc81971b2719af134b844e2eaf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", size = 2738785, upload-time = "2024-12-18T11:29:55.001Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/a3/5f19bc495793546825ab160e530330c2afcee2281c02b5ffafd0b32ac05e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", size = 1996493, upload-time = "2024-12-18T11:29:57.13Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/e8/e0102c2ec153dc3eed88aea03990e1b06cfbca532916b8a48173245afe60/pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", size = 1998544, upload-time = "2024-12-18T11:30:00.681Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/a3/4be70845b555bd80aaee9f9812a7cf3df81550bce6dadb3cfee9c5d8421d/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", size = 2007449, upload-time = "2024-12-18T11:30:02.985Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/9f/b779ed2480ba355c054e6d7ea77792467631d674b13d8257085a4bc7dcda/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", size = 2129460, upload-time = "2024-12-18T11:30:06.55Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/f0/a6ab0681f6e95260c7fbf552874af7302f2ea37b459f9b7f00698f875492/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", size = 2159609, upload-time = "2024-12-18T11:30:09.428Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/2b/e1059506795104349712fbca647b18b3f4a7fd541c099e6259717441e1e0/pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", size = 1819886, upload-time = "2024-12-18T11:30:11.777Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/6d/df49c17f024dfc58db0bacc7b03610058018dd2ea2eaf748ccbada4c3d06/pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad", size = 1980773, upload-time = "2024-12-18T11:30:14.828Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475, upload-time = "2024-12-18T11:30:18.316Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279, upload-time = "2024-12-18T11:30:20.547Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112, upload-time = "2024-12-18T11:30:23.255Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780, upload-time = "2024-12-18T11:30:25.742Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943, upload-time = "2024-12-18T11:30:28.036Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492, upload-time = "2024-12-18T11:30:30.412Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714, upload-time = "2024-12-18T11:30:34.358Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163, upload-time = "2024-12-18T11:30:37.979Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217, upload-time = "2024-12-18T11:30:40.367Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899, upload-time = "2024-12-18T11:30:42.737Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726, upload-time = "2024-12-18T11:30:45.279Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219, upload-time = "2024-12-18T11:30:47.718Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382, upload-time = "2024-12-18T11:30:51.871Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159, upload-time = "2024-12-18T11:30:54.382Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331, upload-time = "2024-12-18T11:30:58.178Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467, upload-time = "2024-12-18T11:31:00.6Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797, upload-time = "2024-12-18T11:31:07.243Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839, upload-time = "2024-12-18T11:31:09.775Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861, upload-time = "2024-12-18T11:31:13.469Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582, upload-time = "2024-12-18T11:31:17.423Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985, upload-time = "2024-12-18T11:31:19.901Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715, upload-time = "2024-12-18T11:31:22.821Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733, upload-time = "2024-12-18T11:31:26.876Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375, upload-time = "2024-12-18T11:31:29.276Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307, upload-time = "2024-12-18T11:31:33.123Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971, upload-time = "2024-12-18T11:31:35.755Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616, upload-time = "2024-12-18T11:31:38.534Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943, upload-time = "2024-12-18T11:31:41.853Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654, upload-time = "2024-12-18T11:31:44.756Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292, upload-time = "2024-12-18T11:31:48.613Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961, upload-time = "2024-12-18T11:31:52.446Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/cc/4c88abc035cc0d8b2646a715d8c4145fad7d95817eb5f18297066b21e20e/pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed", size = 2078970, upload-time = "2026-08-28T10:00:18.938Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/59/fa3ef009cc1b2ca3753fd6869ee461b0b5b67c420cf659e32a12be027a6a/pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0", size = 1917185, upload-time = "2026-08-28T10:00:20.891Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/5e/b3d8901f9775ad928077c3155c36f56fc1c813285e3986ed736a5fbf538e/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655", size = 1955266, upload-time = "2026-08-28T10:00:23.135Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/9e/5522b09d12e8720013f2e4ac174999f40a05501bd15ad2bfa197bc136198/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a", size = 2023466, upload-time = "2026-08-28T10:00:25.489Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/2a/a5267bf2c6c7ded3f282b315e5f0cf2c58008c15b917a652fc32f92d6775/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d", size = 2198448, upload-time = "2026-08-28T10:00:27.676Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/a5/f72c192aba23924065946728e4fba96f73939b90e5aa4f7d41e728aea8d4/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8", size = 2240121, upload-time = "2026-08-28T10:00:30.168Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/9e/0c0cc24149429c030bef1a5c1776150e7e61fcbbfc068c6d1f9de90eb259/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf", size = 2067262, upload-time = "2026-08-28T10:00:32.285Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/00/a2b8690a11d909d9ec9c4eb4d084b4d2e1b227e9b2e74f5926cd39096245/pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464", size = 2095116, upload-time = "2026-08-28T10:00:35.556Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/7e/d3088a2717b7bb316d8d0e64a4b0caf994769e88c56df79df547d75c1dc0/pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64", size = 2134727, upload-time = "2026-08-28T10:00:37.829Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/a4/55a9e0ef61cfd1cbf4289059eb68a3eab765fca8ead6f9991d7de027d42e/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168", size = 2147932, upload-time = "2026-08-28T10:00:40.123Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/25/d2fbc9d59f91f6c50c0d2ec032041c5e3295d68325ade06ec93fa82da43c/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e", size = 2301528, upload-time = "2026-08-28T10:00:42.339Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/76/eccc0528d1421e298b42f85650cf021f0f7c42f502c7e58808db4a672bdb/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13", size = 2322431, upload-time = "2026-08-28T10:00:44.399Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/0c/ffab5a9a0fb82825c44f00dea8ec9d540d2e1e4ab2f1c4f0c32bb8b37fd9/pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7", size = 1958681, upload-time = "2026-08-28T10:00:46.786Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/89/8bb47660fed8c16adf1aae301ba149442e8fd220c126bbea2d24b987abb8/pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0", size = 2046649, upload-time = "2026-08-28T10:00:48.99Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.17.2" @@ -1128,11 +1743,32 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlite-vfs" version = "0.1.0" source = { editable = "." } +[package.optional-dependencies] +fastapi = [ + { name = "fastapi", version = "0.103.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "fastapi", version = "0.124.4", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "fastapi", version = "0.141.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "starlette", version = "0.27.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "starlette", version = "0.44.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, +] + [package.dev-dependencies] dev = [ { name = "twine", version = "4.0.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, @@ -1141,10 +1777,79 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100" }, + { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27" }, +] +provides-extras = ["fastapi"] [package.metadata.requires-dev] dev = [{ name = "twine", specifier = ">=4.0.2" }] +[[package]] +name = "starlette" +version = "0.27.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version < '3.8'", +] +dependencies = [ + { name = "anyio", version = "3.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.8'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/68/559bed5484e746f1ab2ebbe22312f2c25ec62e4b534916d41a8c21147bf8/starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75", size = 51394, upload-time = "2023-05-16T10:59:56.286Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/f8/e2cca22387965584a409795913b774235752be4176d276714e15e1a58884/starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91", size = 66978, upload-time = "2023-05-16T10:59:53.927Z" }, +] + +[[package]] +name = "starlette" +version = "0.44.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/b4/910f693584958b687b8f9c628f8217cfef19a42b64d2de7840814937365c/starlette-0.44.0.tar.gz", hash = "sha256:e35166950a3ccccc701962fe0711db0bc14f2ecd37c6f9fe5e3eae0cbaea8715", size = 2575579, upload-time = "2024-12-28T07:32:56.003Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/c5/7ae467eeddb57260c8ce17a3a09f9f5edba35820fc022d7c55b7decd5d3a/starlette-0.44.0-py3-none-any.whl", hash = "sha256:19edeb75844c16dcd4f9dd72f22f9108c1539f3fc9c4c88885654fef64f85aea", size = 73412, upload-time = "2024-12-28T07:32:53.871Z" }, +] + +[[package]] +name = "starlette" +version = "0.49.3" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "anyio", version = "4.14.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "twine" version = "4.0.2" @@ -1254,6 +1959,36 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.0.7"