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
+296
View File
@@ -0,0 +1,296 @@
"""add_bytes 的契约测试:把内存里的字节直接写进库。
这个 API 存在的理由:受保护的字节码、密文之类**绝不能落盘**的数据只能这样
进库——先写临时文件再 add_file 会把明文留在磁盘上。除了"不经过磁盘"本身,
这里还钉住它相对 add_file 的两条额外保证:
* 补齐缺失的父目录行(少了目录行,export_directory 从根就递归不下去,
export_filesystem() 会静默导出一个空目录);
* ``commit=False`` 的批量写入是"要么全成要么全不成"(写入过程中任何一步失败
都会把同一批里先前的写入一起回滚)。
"""
import os
from datetime import datetime
import helpers
from sqlite_vfs.core import SQLiteVFS
class AddBytesBasicsTest(helpers.VFSTestCase):
"""写进去的字节要能一模一样读回来。"""
def _vfs(self, **kwargs):
vfs = SQLiteVFS(":memory:", **kwargs)
self.addCleanup(vfs.close)
return vfs
def test_writes_bytes_without_a_source_file_on_disk(self):
"""核心契约:内容来自内存,磁盘上不需要(也不该有)任何源文件。"""
vfs = self._vfs()
with helpers.quiet():
ok = vfs.add_bytes("input.bin", b"payload-bytes")
self.assertTrue(ok)
self.assertTrue(vfs.exists("input.bin"))
self.assertEqual(b"payload-bytes", vfs.read_file("input.bin"))
def test_empty_content_roundtrips(self):
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(vfs.add_bytes("empty.bin", b""))
self.assertEqual(b"", vfs.read_file("empty.bin"))
self.assertEqual(0, vfs.get_file_info("empty.bin")["file_size"])
def test_file_size_matches_content_length(self):
vfs = self._vfs()
data = b"x" * 12345
with helpers.quiet():
vfs.add_bytes("big.bin", data)
self.assertEqual(len(data), vfs.get_file_info("big.bin")["file_size"])
def test_accepts_bytearray_and_memoryview(self):
"""调用方手里的常常不是 bytes(bytearray / memoryview 很常见)。"""
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(vfs.add_bytes("ba.bin", bytearray(b"bytearray")))
self.assertTrue(vfs.add_bytes("mv.bin", memoryview(b"memoryview")))
self.assertEqual(b"bytearray", vfs.read_file("ba.bin"))
self.assertEqual(b"memoryview", vfs.read_file("mv.bin"))
def test_mtime_and_permissions_are_recorded(self):
"""时间戳与权限是调用方给的(内存内容没有 stat 可查)。"""
vfs = self._vfs()
stamp = datetime(2020, 1, 2, 3, 4, 5)
with helpers.quiet():
vfs.add_bytes("stamped.bin", b"d", mtime=stamp, permissions=0o600)
info = vfs.get_file_info("stamped.bin")
self.assertEqual(stamp, info["modified_time"])
self.assertEqual(0o600, info["permissions"])
def test_compressible_content_uses_zlib_when_enabled(self):
vfs = self._vfs(compress=True)
data = b"repeat me please. " * 500
with helpers.quiet():
vfs.add_bytes("packed.txt", data)
self.assertEqual(b"repeat me please. " * 500, vfs.read_file("packed.txt"))
self.assertEqual("zlib", vfs.get_blob_info("packed.txt")["compress_algo"])
def test_readonly_handle_refuses_to_write(self):
db = self.path("ro.svfs")
writer = SQLiteVFS(db)
with helpers.quiet():
writer.add_bytes("keep.bin", b"keep")
writer.close()
vfs = SQLiteVFS(db, readonly=True)
self.addCleanup(vfs.close)
with self.assertRaises(PermissionError):
vfs.add_bytes("nope.bin", b"nope")
class AddBytesDedupTest(helpers.VFSTestCase):
"""内容寻址的既有语义对内存写入同样成立。"""
def _vfs(self):
vfs = SQLiteVFS(":memory:")
self.addCleanup(vfs.close)
return vfs
def test_identical_content_is_stored_once(self):
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(vfs.add_bytes("first.bin", b"same-bytes"))
self.assertTrue(vfs.add_bytes("second.bin", b"same-bytes"))
self.assertEqual(1, len(vfs.list_blobs()), "相同内容应当只占一个 blob")
self.assertEqual(b"same-bytes", vfs.read_file("first.bin"))
self.assertEqual(b"same-bytes", vfs.read_file("second.bin"))
def test_replace_overwrites_and_leaves_a_single_entry(self):
vfs = self._vfs()
with helpers.quiet():
vfs.add_bytes("data.bin", b"old")
self.assertTrue(vfs.add_bytes("data.bin", b"new", replace=True))
self.assertEqual(b"new", vfs.read_file("data.bin"))
self.assertEqual({"data.bin"}, helpers.all_paths(vfs))
def test_duplicate_without_replace_is_reported_and_keeps_first_content(self):
"""与 add_file 同样的冲突语义:不覆盖,返回 False。"""
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(vfs.add_bytes("same.bin", b"first"))
self.assertFalse(vfs.add_bytes("same.bin", b"second"))
self.assertEqual(b"first", vfs.read_file("same.bin"))
def test_path_that_normalizes_to_the_root_is_rejected(self):
"""空路径与 "/" 都会规范化成根,写下去就是一行 path='' 的伪条目。
注意只挡"规范化后为空"这一种:含空格的路径是**合法**文件名(与
add_file 保持一致),不该在这里被拦掉。
"""
vfs = self._vfs()
with helpers.quiet():
self.assertFalse(vfs.add_bytes("", b"x"))
self.assertFalse(vfs.add_bytes("/", b"x"))
self.assertFalse(vfs.add_bytes("///", b"x"))
self.assertEqual(set(), helpers.all_paths(vfs))
class AddBytesParentDirectoryTest(helpers.VFSTestCase):
"""父目录行:export 能不能从根递归下去,全看它在不在。"""
def _vfs(self):
vfs = SQLiteVFS(":memory:")
self.addCleanup(vfs.close)
return vfs
def test_missing_parents_are_created_top_down(self):
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(vfs.add_bytes("a/b/c/deep.txt", b"x"))
for directory in ("a", "a/b", "a/b/c"):
self.assertTrue(vfs.is_dir(directory), f"{directory} 应当被补成目录")
self.assertEqual("a/b/c", helpers.path_parents(vfs)["a/b/c/deep.txt"])
def test_make_parents_false_keeps_the_old_behaviour(self):
vfs = self._vfs()
with helpers.quiet():
self.assertTrue(
vfs.add_bytes("a/b/c.txt", b"x", make_parents=False)
)
self.assertFalse(vfs.is_dir("a/b"), "显式关掉时不应该补目录行")
self.assertTrue(vfs.is_file("a/b/c.txt"), "文件本身仍然写进去了")
def test_existing_directory_rows_are_left_alone(self):
"""已经有目录行(正常打包出来的)时不能重复插入。"""
vfs = self._vfs()
with helpers.quiet():
vfs.mk_dir("a")
vfs.add_bytes("a/x.txt", b"x")
count = vfs.conn.execute(
"SELECT COUNT(*) AS n FROM files WHERE snapshot_id = ? AND path = 'a'",
(vfs._active_snapshot(),),
).fetchone()["n"]
self.assertEqual(1, count, f"路径 a 应当只有一行,实际 {count} 行")
def test_export_filesystem_writes_the_whole_nested_tree(self):
"""这条才是 make_parents 的真正目的,也是它必须默认为 True 的原因。"""
vfs = self._vfs()
with helpers.quiet():
vfs.add_bytes("modules/pkg/__init__.pyc", b"bytecode")
vfs.add_bytes("modules/pkg/util.py", b"# source")
vfs.add_bytes("top.bin", b"top")
out = self.path("out")
with helpers.quiet():
vfs.export_filesystem(out)
expected = {
"top.bin": b"top",
"modules": None,
"modules/pkg": None,
"modules/pkg/__init__.pyc": b"bytecode",
"modules/pkg/util.py": b"# source",
}
helpers.assert_tree_equal(self, expected, helpers.read_tree(out))
class AddBytesBatchCommitTest(helpers.VFSTestCase):
"""commit=False:一组文件写成一个事务。"""
def test_batch_persists_after_a_single_commit(self):
db = self.path("batch.svfs")
vfs = SQLiteVFS(db)
with helpers.quiet():
vfs.add_bytes("source.py", b"# source", commit=False)
vfs.add_bytes("module.pyc", b"bytecode", commit=False)
vfs.commit()
vfs.close()
reopened = SQLiteVFS(db)
self.addCleanup(reopened.close)
self.assertTrue(reopened.exists("source.py"))
self.assertEqual(b"bytecode", reopened.read_file("module.pyc"))
def test_uncommitted_batch_is_lost_when_the_caller_never_commits(self):
"""没有 commit 就不该落库——否则"一模块一事务"根本没有意义。"""
db = self.path("nocommit.svfs")
vfs = SQLiteVFS(db)
with helpers.quiet():
vfs.add_bytes("source.py", b"# source", commit=False)
vfs.close()
reopened = SQLiteVFS(db)
self.addCleanup(reopened.close)
self.assertFalse(reopened.exists("source.py"))
def test_a_failed_write_rolls_back_the_whole_batch(self):
"""失败要回滚整批(含之前那些 commit=False 的调用),不能留半套数据。"""
vfs = SQLiteVFS(":memory:")
self.addCleanup(vfs.close)
with helpers.quiet():
self.assertTrue(vfs.add_bytes("good.bin", b"good", commit=False))
self.assertFalse(vfs.add_bytes("", b"bad", commit=False))
self.assertFalse(
vfs.exists("good.bin"), "整批回滚后,先前写入的文件不该留下"
)
def test_commit_is_public_and_usable_on_a_readonly_handle(self):
"""commit 只转发给连接;只读连接上没有待提交的事务,不该炸。"""
db = self.path("ro_commit.svfs")
writer = SQLiteVFS(db)
with helpers.quiet():
writer.add_bytes("keep.bin", b"keep")
writer.close()
vfs = SQLiteVFS(db, readonly=True)
self.addCleanup(vfs.close)
vfs.commit()
class AddFileDelegationTest(helpers.VFSTestCase):
"""add_file 现在是 add_bytes 的"从磁盘读"外壳,行为不能跑偏。"""
def _vfs(self):
vfs = SQLiteVFS(":memory:")
self.addCleanup(vfs.close)
return vfs
def test_add_file_also_creates_parent_rows(self):
"""修的是同一个静默 bug:只写文件行的话,导出的目录树是空的。"""
vfs = self._vfs()
src = helpers.write_file(self.path("deep.txt"), b"deep")
with helpers.quiet():
self.assertTrue(vfs.add_file(src, "doc/sub/deep.txt"))
self.assertTrue(vfs.is_dir("doc"))
self.assertTrue(vfs.is_dir("doc/sub"))
def test_add_file_keeps_recording_the_source_stat_times(self):
vfs = self._vfs()
src = helpers.write_file(self.path("t.txt"), b"t")
os.utime(src, (1600000000, 1600000000))
with helpers.quiet():
vfs.add_file(src, "t.txt")
info = vfs.get_file_info("t.txt")
self.assertEqual(
1600000000, int(info["modified_time"].timestamp()),
"modified_time 应当来自源文件的 st_mtime",
)
def test_add_file_returns_false_for_a_missing_source(self):
vfs = self._vfs()
with helpers.quiet():
self.assertFalse(vfs.add_file(self.path("nope.txt"), "nope.txt"))
self.assertFalse(vfs.exists("nope.txt"))
+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):