Files
svfs/tests/test_contrib_optional.py
chakcy ecdfacc620 fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、
界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。

缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md):
- 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致
- list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归
- CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda
- add_file 缺少 commit(直接调用 API 时静默丢数据)
- LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名
- 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob
- FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件

存储层(schema v3,格式变更,提供 vfsmigrate 迁移):
- 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享
- files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本
- 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证
- 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压
- get_file_info 不再返回 content(元数据查询不该搬 BLOB)

新增能力:
- 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖)
- 内容寻址发布到对象存储 + 逐对象校验的取回客户端
- 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍

其它:
- P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变)
- read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取)
- requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9

测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。
证据见 docs/evidence-*.txt。
2026-09-14 00:11:42 +08:00

102 lines
3.8 KiB
Python

"""验证"插件是可选依赖"这一契约。
这个模块在**有/没有** 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()