feat: SQLiteVFS.add_bytes()/commit()/rename_snapshot()
tests / ubuntu-latest / py3.13 (push) Has been cancelled
tests / windows-latest / py3.13 (push) Has been cancelled
tests / ubuntu-latest / py3.13 (pull_request) Has been cancelled
tests / windows-latest / py3.13 (pull_request) Has been cancelled

三处写入侧的缺口,都是被下游"模块银行"(module_bank,把模块打包进 svfs 包
再按需导入)真实逼出来的:

- add_bytes(rel_path, data, ...):从内存写文件,不经过磁盘。受保护的字节码、
  密文这类绝对不能落盘的内容因此可以直接入库——先写临时文件再 add_file 会把
  明文留在磁盘上,而磁盘残留是这类工具最容易被忽视的泄漏点。
- make_parents=True(默认):补齐缺失的父目录行。list_directory 是按
  parent_path 过滤的,而"某个目录存在"的唯一标志就是它自己那一行——少一层
  目录行,export_directory 从根就递归不下去,export_filesystem() 会静默导出
  一个空目录。add_file 一并受益(内部改为读盘后调 add_bytes,行为不变,
  含 created_time 取 st_ctime 这个细节)。
- commit=False + 公开的 commit():一组相关文件可以写成一个事务,中途任何一步
  失败整批回滚,不会在库里留下半套数据。
- rename_snapshot(id, name):create/list/delete 都有,唯独缺改名;而"把发布名
  落在快照上"是版本管理的基本动作。**定格一个版本靠的正是它**:先给当前快照
  改名,再另建一个工作快照接手后续写入——直接 create_snapshot(name) 的话,
  名字落在会继续被写入的那一版上,下一次改动就把这个"版本"改了。
- DEFAULT_DIR_PERMISSIONS:补出来的目录行用 0o755(表的默认值 0o644 是按文件
  定的,套到目录上会让导出的目录不可进入)。
- 版本 0.3.0 → 0.4.0(add_file 开始建父目录行属于可见的行为变化)。

测试:tests/test_core_add_bytes.py(22 条:往返/空内容/bytearray/时间戳与权限/
压缩/去重/覆盖/空路径/只读拒绝/父目录行与 export 目录树/批量提交与回滚/
add_file 委托行为)+ tests/test_snapshots.py 新增 5 条改名用例(只改名字不动
内容与摘要/空名拒绝/不存在的快照报错/允许重名/改名+派生工作快照之后
已定格的版本不被后续写入改动)。完整测试套件逐个文件跑通。
This commit is contained in:
2026-09-20 14:58:17 +08:00
parent 9dc2dde59f
commit 8489b4052d
4 changed files with 524 additions and 18 deletions
+72
View File
@@ -122,6 +122,78 @@ class SnapshotLifecycleTest(helpers.VFSTestCase):
self.assertEqual([1], [s["id"] for s in vfs.list_snapshots()])
self.assertIsNone(vfs.snapshot_id, "当前快照被删后应当需要重新解析")
def test_rename_snapshot_changes_the_name_only(self):
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
before = vfs.compute_digest(store=False)
self.assertEqual("release-1.0", vfs.rename_snapshot(1, "release-1.0"))
self.assertEqual("release-1.0", vfs.get_stats()["snapshot_name"])
self.assertEqual(["release-1.0"],
[s["name"] for s in vfs.list_snapshots()])
self.assertEqual(b"a", vfs.read_file("a.txt"), "改名不该动内容")
self.assertEqual(before, vfs.compute_digest(store=False),
"改名不该动摘要")
def test_rename_snapshot_rejects_blank_name(self):
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
for blank in ("", " ", None):
with self.assertRaises(ValueError):
vfs.rename_snapshot(1, blank)
def test_rename_missing_snapshot_raises(self):
"""与 use_snapshot 一致:快照不存在就报 FileNotFoundError。"""
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"a")
with helpers.quiet():
vfs.add_file(src, "a.txt")
with self.assertRaises(FileNotFoundError):
vfs.rename_snapshot(987654, "nope")
def test_rename_allows_duplicate_names(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", copy_entries=True)
vfs.rename_snapshot(1, "dup")
vfs.rename_snapshot(2, "dup")
self.assertEqual(["dup", "dup"],
[s["name"] for s in vfs.list_snapshots()])
def test_rename_then_fork_freezes_the_named_version(self):
"""定格一个版本的组合动作:把当前快照改名,再另建一个工作快照接手写入。
这是"版本号该落在不再变动的那份内容上"的最小保证——不这样做的话,
名字会落在**继续被写入**的快照上,下一次改动就把这个版本改了。
"""
vfs = self._vfs()
src = helpers.write_file(self.path("a.txt"), b"v1")
with helpers.quiet():
vfs.add_file(src, "a.txt")
vfs.rename_snapshot(vfs._active_snapshot(), "release-1.0")
work = vfs.create_snapshot("work", copy_entries=True)
# 在工作快照里改成 v2,已定格的 release-1.0 必须原封不动
src2 = helpers.write_file(self.path("b.txt"), b"v2")
with helpers.quiet():
vfs.add_file(src2, "a.txt", replace=True)
self.assertEqual(b"v2", vfs.read_file("a.txt"))
release = SQLiteVFS(self.path("s.svfs"), snapshot_id=1)
self.addCleanup(release.close)
self.assertEqual("release-1.0", release.get_stats()["snapshot_name"])
self.assertEqual(b"v1", release.read_file("a.txt"),
"定格之后的版本不该被后续写入改动")
self.assertEqual(work, 2)
class TimeTravelTest(helpers.VFSTestCase):
def _pack_v1_then_v2(self):