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。
This commit is contained in:
@@ -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"<div id=app></div>",
|
||||
"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"<div id=app></div>",
|
||||
"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"<div id=app></div>",
|
||||
"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()
|
||||
Reference in New Issue
Block a user