"""快照与时间旅行。 一个库里可以存同一棵树的历史版本:``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()