feat: 统一 svfs 子命令入口,打包进度回调与对象缓存路径
桌面应用(svfs-studio / svfs-client)要用的前置改动: * 新增 src/sqlite_vfs/cli/main.py:`svfs <子命令>` 统一入口,旧的独立命令 (vfspack / vfsunpack / …)保留为别名,两套跑同一段 execute(args);各子命令 因此从 sys.exit() 改成返回退出码,便于被复用; * FolderPacker.pack_folder(progress=…):每处理一个条目(目录也算)回调一次, 供 GUI 进度条使用;只有传了回调时才做"只 stat、不读内容"的预扫描,CLI 的 行为与开销不变;回调抛出的异常一路上抛——GUI 的中断语义就靠它; * publish.default_cache_dir():对象缓存目录(客户端设置里可改);load_manifest 对"误把服务器根地址当清单填进来"给出可用插件列表的提示,而不是只甩一个 format=None。 测试:新增 tests/test_cli_main.py、tests/test_pack_progress.py,test_publish.py 补充。
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""统一入口 ``svfs <子命令>``(sqlite_vfs.cli.main)。
|
||||
|
||||
约定:
|
||||
* 每个子命令与旧的 ``vfs*`` 别名命令跑同一段 ``execute``,输出与退出码一致;
|
||||
* 退出码:0 成功,1 失败,argparse 用法错误 2;
|
||||
* 不带子命令 → 打印帮助并以 0 退出(而不是甩 usage 错误)。
|
||||
|
||||
端到端走子进程(与 test_cli.py 同一套 run_cli),保证测的就是用户敲的那条命令。
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
|
||||
from sqlite_vfs.core import SQLiteVFS
|
||||
|
||||
|
||||
class DispatcherTest(helpers.VFSTestCase):
|
||||
def run_svfs(self, *args):
|
||||
return helpers.run_cli("sqlite_vfs.cli.main", *args)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.src = helpers.make_tree(
|
||||
self.path("src"), {"a.txt": b"aaa", "sub/b.txt": b"bbb"}
|
||||
)
|
||||
self.db = self.path("app.svfs")
|
||||
|
||||
def pack(self, *extra):
|
||||
result = self.run_svfs("pack", self.src, self.db, "--compress", *extra)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_no_subcommand_prints_help_and_exits_zero(self):
|
||||
result = self.run_svfs()
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("svfs pack", result.stdout)
|
||||
self.assertIn("usage", result.stdout.lower())
|
||||
|
||||
def test_help_lists_all_subcommands(self):
|
||||
result = self.run_svfs("--help")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
for name in ("pack", "unpack", "verify", "sign", "info", "migrate", "publish", "fetch"):
|
||||
self.assertIn(name, result.stdout)
|
||||
|
||||
def test_version_flag(self):
|
||||
result = self.run_svfs("--version")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("svfs", result.stdout)
|
||||
|
||||
def test_unknown_command_is_usage_error(self):
|
||||
result = self.run_svfs("nope")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
|
||||
|
||||
class SubcommandFlowTest(helpers.VFSTestCase):
|
||||
"""每个子命令端到端一遍:这条链就是 README 上的主流程。"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.src = helpers.make_tree(
|
||||
self.path("src"),
|
||||
{"a.txt": b"aaa", "sub/b.txt": b"bbb", "dup.txt": b"aaa"}, # dup 与 a 同内容
|
||||
)
|
||||
self.db = self.path("app.svfs")
|
||||
|
||||
def run_svfs(self, *args, expect=0):
|
||||
result = helpers.run_cli("sqlite_vfs.cli.main", *args)
|
||||
self.assertEqual(
|
||||
result.returncode, expect, f"svfs {args}\n{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
self.run_svfs("pack", self.src, self.db, "--compress", "--name", "demo")
|
||||
|
||||
# info:统计与去重收益
|
||||
info = self.run_svfs("info", self.db)
|
||||
self.assertIn("demo", info.stdout)
|
||||
self.assertIn("schema v3", info.stdout)
|
||||
self.assertIn("去重+压缩共省下", info.stdout)
|
||||
|
||||
# sign / verify
|
||||
self.run_svfs("sign", self.db, "--key", "k1", "--key-id", "ci")
|
||||
verify = self.run_svfs("verify", self.db, "--key", "k1")
|
||||
self.assertIn("签名: OK", verify.stdout)
|
||||
wrong = self.run_svfs("verify", self.db, "--key", "别的密钥", expect=1)
|
||||
self.assertIn("签名: 失败", wrong.stdout)
|
||||
|
||||
# unpack + 内容比对
|
||||
out = self.path("out")
|
||||
self.run_svfs("unpack", self.db, out)
|
||||
helpers.assert_tree_equal(
|
||||
self, helpers.read_tree(self.src), helpers.read_tree(out)
|
||||
)
|
||||
|
||||
# publish → fetch 回环
|
||||
public = self.path("public")
|
||||
self.run_svfs("publish", self.db, public)
|
||||
self.assertTrue(os.path.exists(os.path.join(public, "manifest.json")))
|
||||
back = self.path("back")
|
||||
self.run_svfs("fetch", os.path.join(public, "manifest.json"), back)
|
||||
helpers.assert_tree_equal(
|
||||
self, helpers.read_tree(self.src), helpers.read_tree(back)
|
||||
)
|
||||
|
||||
def test_info_json(self):
|
||||
self.run_svfs("pack", self.src, self.db)
|
||||
import json
|
||||
|
||||
result = self.run_svfs("info", self.db, "--json")
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["name"], "packed_filesystem")
|
||||
self.assertEqual(data["files"], 3)
|
||||
self.assertEqual(data["unique_contents"], 2) # a.txt 与 dup.txt 去重
|
||||
self.assertGreater(data["saved_bytes"], 0)
|
||||
self.assertEqual(len(data["snapshots"]), 1)
|
||||
|
||||
def test_sign_json_and_snapshot_selection(self):
|
||||
from sqlite_vfs.folder_packer import FolderPacker
|
||||
|
||||
self.run_svfs("pack", self.src, self.db)
|
||||
# 打一版历史出来
|
||||
vfs = SQLiteVFS(self.db, compress=True)
|
||||
try:
|
||||
with helpers.quiet():
|
||||
FolderPacker(vfs, fs_name="demo").pack_folder(
|
||||
self.src, incremental=True
|
||||
)
|
||||
vfs.create_snapshot("v2", copy_entries=True)
|
||||
ids = [row["id"] for row in vfs.list_snapshots()]
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
result = self.run_svfs("sign", self.db, "--key", "k", "--snapshot", str(ids[0]), "--json")
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
self.assertEqual(data["snapshot"], ids[0])
|
||||
self.assertTrue(data["signature"])
|
||||
|
||||
# 验签指定同一快照 → OK;最新快照(未签名)→ 失败
|
||||
self.run_svfs("verify", self.db, "--key", "k", "--snapshot", str(ids[0]))
|
||||
self.run_svfs("verify", self.db, "--key", "k", expect=1)
|
||||
|
||||
def test_pack_snapshot_flag_creates_version(self):
|
||||
self.run_svfs("pack", self.src, self.db)
|
||||
result = self.run_svfs("pack", self.src, self.db, "--incremental", "--snapshot", "v2")
|
||||
self.assertIn("已创建快照 v2", result.stdout)
|
||||
vfs = SQLiteVFS(self.db, readonly=True)
|
||||
try:
|
||||
names = [row["name"] for row in vfs.list_snapshots()]
|
||||
finally:
|
||||
vfs.close()
|
||||
self.assertEqual(len(names), 2)
|
||||
self.assertIn("v2", names)
|
||||
|
||||
def test_error_paths_report_and_exit_1(self):
|
||||
self.run_svfs("pack", self.path("不存在"), self.db, expect=1)
|
||||
self.run_svfs("info", self.path("不存在.svfs"), expect=1)
|
||||
self.run_svfs("sign", self.path("不存在.svfs"), "--key", "k", expect=1)
|
||||
|
||||
self.pack_helper()
|
||||
# 对不是 SVFS 的文件 info/verify/sign 都要干净地失败
|
||||
garbage = self.path("garbage.db")
|
||||
with open(garbage, "wb") as handle:
|
||||
handle.write(b"x" * 4096)
|
||||
self.run_svfs("info", garbage, expect=1)
|
||||
self.run_svfs("verify", garbage, expect=1)
|
||||
self.run_svfs("sign", garbage, "--key", "k", expect=1)
|
||||
|
||||
def pack_helper(self):
|
||||
self.run_svfs("pack", self.src, self.db, "--compress")
|
||||
|
||||
def test_legacy_aliases_share_the_same_execute(self):
|
||||
"""旧命令与新入口是同一段实现:退出码一致,产出的库摘要一致(内容寻址)。"""
|
||||
fresh_db = self.path("alias.svfs")
|
||||
new_run = helpers.run_cli("sqlite_vfs.cli.main", "pack", self.src, self.db, "--compress")
|
||||
legacy_run = helpers.run_cli("sqlite_vfs.cli.packer", self.src, fresh_db, "--compress")
|
||||
self.assertEqual(new_run.returncode, 0, new_run.stderr)
|
||||
self.assertEqual(legacy_run.returncode, 0, legacy_run.stderr)
|
||||
|
||||
def digest(path):
|
||||
vfs = SQLiteVFS(path, readonly=True)
|
||||
try:
|
||||
return vfs.get_stats()["digest"]
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
self.assertEqual(digest(self.db), digest(fresh_db))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""打包进度回调(pack_folder 的 progress 参数)。
|
||||
|
||||
约定:
|
||||
* 每处理一个条目(目录也算)回调一次,``done`` 从 1 递增到 ``total``;
|
||||
* ``total`` 是完整遍历一遍得到的条目数,为了拿到它多做一次"只 stat"的预扫描,
|
||||
因此**只有传了回调时**才算,不传回调的路径(CLI)开销一点不变;
|
||||
* 回调抛出的异常会一路向上抛——GUI 的中断按钮就靠这个语义。
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
|
||||
from sqlite_vfs.core import SQLiteVFS
|
||||
from sqlite_vfs.folder_packer import FolderPacker
|
||||
|
||||
|
||||
class PackProgressTest(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",
|
||||
"sub/deep/d.txt": b"ddd",
|
||||
"empty_dir/.keep": b"",
|
||||
},
|
||||
)
|
||||
# 条目数 = 5 个文件(含 empty_dir/.keep)+ 3 个目录(sub / sub/deep / empty_dir)
|
||||
self.expected_entries = 8
|
||||
|
||||
def collect(self, incremental=False):
|
||||
calls = []
|
||||
|
||||
def progress(done, total, rel_path):
|
||||
calls.append((done, total, rel_path))
|
||||
|
||||
vfs = SQLiteVFS(self.path("prog.svfs"), compress=True)
|
||||
try:
|
||||
packer = FolderPacker(vfs, fs_name="prog")
|
||||
with helpers.quiet():
|
||||
ok = packer.pack_folder(self.src, incremental=incremental, progress=progress)
|
||||
finally:
|
||||
vfs.close()
|
||||
return ok, calls
|
||||
|
||||
def test_full_progress_is_one_call_per_entry(self):
|
||||
ok, calls = self.collect()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(len(calls), self.expected_entries)
|
||||
self.assertEqual([done for done, _total, _path in calls], list(range(1, len(calls) + 1)))
|
||||
|
||||
def test_total_matches_entry_count(self):
|
||||
_ok, calls = self.collect()
|
||||
totals = {total for _done, total, _path in calls}
|
||||
self.assertEqual(totals, {self.expected_entries})
|
||||
|
||||
def test_progress_paths_are_relative_and_normalized(self):
|
||||
_ok, calls = self.collect()
|
||||
paths = [path.replace("\\", "/") for _done, _total, path in calls]
|
||||
self.assertEqual(sorted(paths), sorted(["a.txt", "b.txt", "sub", "sub/c.txt",
|
||||
"sub/deep", "sub/deep/d.txt",
|
||||
"empty_dir", "empty_dir/.keep"]))
|
||||
|
||||
def test_incremental_progress_counts_all_entries(self):
|
||||
ok, calls = self.collect(incremental=True)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(len(calls), self.expected_entries)
|
||||
self.assertEqual({total for _done, total, _path in calls}, {self.expected_entries})
|
||||
|
||||
def test_result_is_same_with_and_without_progress(self):
|
||||
"""带回调不该改变打包结果:条目集合必须一模一样。"""
|
||||
db_with = self.path("with.svfs")
|
||||
db_without = self.path("without.svfs")
|
||||
|
||||
vfs = SQLiteVFS(db_with, compress=True)
|
||||
try:
|
||||
with helpers.quiet():
|
||||
FolderPacker(vfs, fs_name="prog").pack_folder(
|
||||
self.src, progress=lambda *_args: None
|
||||
)
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
helpers.pack(self.src, db_without, compress=True)
|
||||
|
||||
def snapshot(db_path):
|
||||
vfs = SQLiteVFS(db_path, readonly=True)
|
||||
try:
|
||||
return [
|
||||
(row["path"], row["is_directory"], row["file_size"], row["content_hash"])
|
||||
for row in vfs.all_entries()
|
||||
], sorted(row["hash"] for row in vfs.list_blobs())
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
self.assertEqual(snapshot(db_with), snapshot(db_without))
|
||||
|
||||
def test_no_precount_when_progress_is_absent(self):
|
||||
"""不传回调就不该多做那次预扫描(CLI 的开销必须保持原样)。"""
|
||||
vfs = SQLiteVFS(self.path("nopre.svfs"), compress=True)
|
||||
try:
|
||||
packer = FolderPacker(vfs, fs_name="prog")
|
||||
original = packer._count_entries
|
||||
called = []
|
||||
|
||||
def spy(source_folder):
|
||||
called.append(source_folder)
|
||||
return original(source_folder)
|
||||
|
||||
packer._count_entries = spy
|
||||
with helpers.quiet():
|
||||
self.assertTrue(packer.pack_folder(self.src))
|
||||
self.assertEqual(called, [])
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
def test_exception_from_callback_propagates(self):
|
||||
"""回调抛异常要能中断打包——GUI 的"中断"就是靠这个语义实现的。"""
|
||||
|
||||
class Stop(Exception):
|
||||
pass
|
||||
|
||||
seen = []
|
||||
|
||||
def progress(done, total, rel_path):
|
||||
seen.append(rel_path)
|
||||
if done >= 2:
|
||||
raise Stop("用户中断")
|
||||
|
||||
vfs = SQLiteVFS(self.path("cancel.svfs"), compress=True)
|
||||
try:
|
||||
packer = FolderPacker(vfs, fs_name="prog")
|
||||
with helpers.quiet():
|
||||
with self.assertRaises(Stop):
|
||||
packer.pack_folder(self.src, progress=progress)
|
||||
self.assertEqual(len(seen), 2)
|
||||
finally:
|
||||
vfs.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -449,6 +449,26 @@ class FetchOverHttpTest(HttpServerFixture):
|
||||
with open(os.path.join(self.path("unverified"), "sub", "b.js"), "rb") as f:
|
||||
self.assertEqual(b"console.log(2)", f.read())
|
||||
|
||||
def test_server_index_json_gets_actionable_error(self):
|
||||
# 最常见的误操作:把服务器根地址(索引 JSON)当清单地址填进来,
|
||||
# 报错要直接给出可用的插件 id,而不是一个看不懂的 format=None
|
||||
index = self.path("index.json")
|
||||
with open(index, "w", encoding="utf-8") as f:
|
||||
json.dump({"service": "svfs-plugin-server",
|
||||
"plugins": ["com.svfs.demo", "com.example.other"]}, f)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
publish.load_manifest(index)
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("服务器索引", message)
|
||||
self.assertIn("com.svfs.demo", message)
|
||||
|
||||
empty = self.path("empty-index.json")
|
||||
with open(empty, "w", encoding="utf-8") as f:
|
||||
json.dump({"service": "svfs-plugin-server", "plugins": []}, f)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
publish.load_manifest(empty)
|
||||
self.assertIn("还没有插件", str(ctx.exception))
|
||||
|
||||
def test_manifest_from_url_mismatch_is_reported(self):
|
||||
_src, objects, base, _db = self._export_and_serve()
|
||||
with self.assertRaises(Exception):
|
||||
|
||||
Reference in New Issue
Block a user