blender-asset-tracer/tests/test_pack_folder.py

425 lines
15 KiB
Python

"""Tests for the folder scanner and the 'bat pack-folder' subcommand."""
import argparse
import pathlib
import shutil
import zipfile
from shutil import copyfile
import pytest
from blender_asset_tracer import blendfile
from blender_asset_tracer.pack import folder as folder_scan
BLENDFILES = pathlib.Path(__file__).with_name("blendfiles")
def make_blend(path: pathlib.Path) -> pathlib.Path:
"""Create a file that passes the blend file magic check."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"BLENDER-v303RENDH" + b"\0" * 32)
return path
def names(paths, relative_to: pathlib.Path):
return sorted(str(p.relative_to(relative_to)) for p in paths)
@pytest.fixture(autouse=True)
def close_cached_blendfiles():
yield
blendfile.close_all_cached()
class TestFindBlendFiles:
def test_finds_files_recursively_in_sorted_order(self, tmp_path):
make_blend(tmp_path / "b.blend")
make_blend(tmp_path / "a.blend")
make_blend(tmp_path / "sub" / "deep" / "c.blend")
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == [
"a.blend",
"b.blend",
"sub/deep/c.blend",
]
assert scan.skipped == []
assert scan.unreadable_dirs == []
def test_ignores_backup_and_other_files(self, tmp_path):
make_blend(tmp_path / "scene.blend")
make_blend(tmp_path / "scene.blend1")
make_blend(tmp_path / "scene.blend2")
(tmp_path / "notes.txt").write_text("hi")
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == ["scene.blend"]
# Non-.blend files aren't even considered, so they're not 'skipped'.
assert scan.skipped == []
def test_uppercase_suffix_is_found(self, tmp_path):
make_blend(tmp_path / "SCENE.BLEND")
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == ["SCENE.BLEND"]
def test_hidden_directories_are_pruned_by_default(self, tmp_path):
make_blend(tmp_path / "scene.blend")
make_blend(tmp_path / ".git" / "hidden.blend")
make_blend(tmp_path / "__pycache__" / "cached.blend")
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == ["scene.blend"]
def test_custom_ignore_dirs(self, tmp_path):
make_blend(tmp_path / "scene.blend")
make_blend(tmp_path / "backup_2024" / "old.blend")
make_blend(tmp_path / ".git" / "hidden.blend")
scan = folder_scan.find_blend_files(tmp_path, ignore_dirs=["backup_*"])
# Only the custom pattern is applied, so '.git' is no longer pruned.
assert names(scan.blendfiles, tmp_path) == [".git/hidden.blend", "scene.blend"]
def test_non_blendfile_content_is_skipped(self, tmp_path):
make_blend(tmp_path / "good.blend")
(tmp_path / "bad.blend").write_bytes(b"this is not a blend file")
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == ["good.blend"]
assert len(scan.skipped) == 1
skipped_path, reason = scan.skipped[0]
assert skipped_path == tmp_path / "bad.blend"
assert reason == "not a blend file"
def test_validation_can_be_disabled(self, tmp_path):
make_blend(tmp_path / "good.blend")
(tmp_path / "bad.blend").write_bytes(b"this is not a blend file")
scan = folder_scan.find_blend_files(tmp_path, validate=False)
assert names(scan.blendfiles, tmp_path) == ["bad.blend", "good.blend"]
assert scan.skipped == []
def test_compressed_blendfile_is_accepted(self, tmp_path):
copyfile(
str(BLENDFILES / "basic_file_compressed.blend"),
str(tmp_path / "compressed.blend"),
)
scan = folder_scan.find_blend_files(tmp_path)
assert names(scan.blendfiles, tmp_path) == ["compressed.blend"]
def test_unreadable_directory_is_reported(self, tmp_path):
make_blend(tmp_path / "scene.blend")
bad_dir = tmp_path / "locked"
make_blend(bad_dir / "hidden.blend")
bad_dir.chmod(0o000)
try:
scan = folder_scan.find_blend_files(tmp_path)
finally:
bad_dir.chmod(0o755)
assert names(scan.blendfiles, tmp_path) == ["scene.blend"]
assert len(scan.unreadable_dirs) == 1
assert scan.unreadable_dirs[0][0] == bad_dir
def test_symlinked_dir_skipped_by_default(self, tmp_path):
make_blend(tmp_path / "root" / "scene.blend")
make_blend(tmp_path / "elsewhere" / "linked.blend")
(tmp_path / "root" / "link").symlink_to(tmp_path / "elsewhere")
scan = folder_scan.find_blend_files(tmp_path / "root")
assert names(scan.blendfiles, tmp_path / "root") == ["scene.blend"]
def test_symlinked_dir_followed_on_request(self, tmp_path):
make_blend(tmp_path / "root" / "scene.blend")
make_blend(tmp_path / "elsewhere" / "linked.blend")
(tmp_path / "root" / "link").symlink_to(tmp_path / "elsewhere")
scan = folder_scan.find_blend_files(tmp_path / "root", follow_symlinks=True)
assert names(scan.blendfiles, tmp_path / "root") == [
"link/linked.blend",
"scene.blend",
]
def test_symlink_loop_does_not_hang(self, tmp_path):
root = tmp_path / "root"
make_blend(root / "scene.blend")
(root / "loop").symlink_to(root)
scan = folder_scan.find_blend_files(root, follow_symlinks=True)
assert names(scan.blendfiles, root) == ["scene.blend"]
class TestLatestOnly:
def test_keeps_highest_version(self, tmp_path):
for version in (0, 3, 12, 7):
make_blend(tmp_path / ("shot_v%03d.blend" % version))
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == ["shot_v012.blend"]
assert len(scan.skipped) == 3
assert all(reason == "superseded by a newer version" for _, reason in scan.skipped)
def test_keeps_unversioned_files(self, tmp_path):
make_blend(tmp_path / "shot_v001.blend")
make_blend(tmp_path / "shot_v002.blend")
make_blend(tmp_path / "shot.blend")
make_blend(tmp_path / "no_version_here.blend")
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == [
"no_version_here.blend",
"shot.blend",
"shot_v002.blend",
]
def test_variants_are_their_own_series(self, tmp_path):
for name in (
"shot_v001.blend",
"shot_v003.blend",
"shot_v001_light.blend",
"shot_v002_light.blend",
"shot_v005_work.blend",
):
make_blend(tmp_path / name)
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == [
"shot_v002_light.blend",
"shot_v003.blend",
"shot_v005_work.blend",
]
def test_last_version_marker_wins(self, tmp_path):
make_blend(tmp_path / "char_v001_rig_v002.blend")
make_blend(tmp_path / "char_v001_rig_v007.blend")
make_blend(tmp_path / "char_v002_rig_v001.blend")
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == [
"char_v001_rig_v007.blend",
"char_v002_rig_v001.blend",
]
def test_versions_are_grouped_per_directory_and_name(self, tmp_path):
make_blend(tmp_path / "anim" / "shot_v001.blend")
make_blend(tmp_path / "anim" / "shot_v002.blend")
make_blend(tmp_path / "light" / "shot_v001.blend")
make_blend(tmp_path / "light" / "other_v005.blend")
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == [
"anim/shot_v002.blend",
"light/other_v005.blend",
"light/shot_v001.blend",
]
def test_case_insensitive_version_match(self, tmp_path):
make_blend(tmp_path / "SHOT_V001.BLEND")
make_blend(tmp_path / "SHOT_V002.BLEND")
scan = folder_scan.find_blend_files(tmp_path, latest_only=True)
assert names(scan.blendfiles, tmp_path) == ["SHOT_V002.BLEND"]
def folder_parser():
from blender_asset_tracer.cli import pack as cli_pack
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
cli_pack.add_folder_parser(subparsers)
return parser
class TestCLIFolderParsing:
def test_requires_target(self):
with pytest.raises(SystemExit):
folder_parser().parse_args(["pack-folder", "/some/folder"])
def test_requires_folder(self):
with pytest.raises(SystemExit):
folder_parser().parse_args(["pack-folder", "-t", "out.zip"])
def test_defaults(self):
args = folder_parser().parse_args(
["pack-folder", "/some/folder", "-t", "out.zip"]
)
assert args.folder == pathlib.Path("/some/folder")
assert args.target == "out.zip"
assert args.project is None
assert args.latest_only is False
assert args.keep_hierarchy is False
assert args.follow_symlinks is False
assert args.validate is True
assert args.ignore_dir == list(folder_scan.DEFAULT_IGNORE_DIRS)
def test_flags(self):
args = folder_parser().parse_args([
"pack-folder", "/some/folder", "-t", "out.zip",
"--latest-only", "--keep-hierarchy", "--no-validate",
"--ignore-dir", "backup*", "old*",
])
assert args.latest_only is True
assert args.keep_hierarchy is True
assert args.validate is False
assert args.ignore_dir == ["backup*", "old*"]
def run_pack_folder(argv):
args = folder_parser().parse_args(argv)
args.func(args)
def make_linked_pair(folder: pathlib.Path) -> None:
"""Copy linked_cube.blend + the basic_file.blend it links to into `folder`."""
folder.mkdir(parents=True, exist_ok=True)
for name in ("linked_cube.blend", "basic_file.blend"):
copyfile(str(BLENDFILES / name), str(folder / name))
def make_absolute_link(blendpath: pathlib.Path, target: pathlib.Path) -> None:
"""Rewrite the library path of `blendpath` to the absolute path of `target`."""
bfile = blendfile.BlendFile(blendpath, mode="r+b")
try:
library = bfile.code_index[b"LI"][0]
abspath = str(target).encode()
library[b"filepath"] = abspath
library[b"name"] = abspath
finally:
bfile.close()
blendfile.close_all_cached()
class TestPackFolderEndToEnd:
def test_packs_folder_to_directory(self, tmp_path):
source = tmp_path / "project"
make_linked_pair(source)
target = tmp_path / "pack"
run_pack_folder(["pack-folder", str(source), "-t", str(target)])
assert (target / "linked_cube.blend").exists()
assert (target / "basic_file.blend").exists()
assert (target / "pack-info.txt").exists()
# Everything was inside the project, so nothing is packed outside it.
assert not (target / "_outside_project").exists()
info = (target / "pack-info.txt").read_text()
assert "2 blend files" in info
def test_packs_folder_to_zip(self, tmp_path):
source = tmp_path / "project"
make_linked_pair(source)
target = tmp_path / "pack.zip"
run_pack_folder(["pack-folder", str(source), "-t", str(target)])
with zipfile.ZipFile(str(target)) as inzip:
assert inzip.testzip() is None
arcnames = set(inzip.namelist())
assert "linked_cube.blend" in arcnames
assert "basic_file.blend" in arcnames
assert "pack-info.txt" in arcnames
def test_nested_blendfiles_keep_their_relative_place(self, tmp_path):
source = tmp_path / "project"
make_linked_pair(source / "shots" / "sq01")
target = tmp_path / "pack"
run_pack_folder(["pack-folder", str(source), "-t", str(target)])
assert (target / "shots" / "sq01" / "linked_cube.blend").exists()
assert (target / "shots" / "sq01" / "basic_file.blend").exists()
def test_dependency_that_is_also_packed_stays_in_place(self, tmp_path):
"""A blend file linked by absolute path is packed only at its own spot."""
source = tmp_path / "project"
make_linked_pair(source)
make_absolute_link(source / "linked_cube.blend", source / "basic_file.blend")
target = tmp_path / "pack"
run_pack_folder(["pack-folder", str(source), "-t", str(target)])
assert (target / "linked_cube.blend").exists()
assert (target / "basic_file.blend").exists()
# The absolute path was rewritten to the in-pack file, rather than
# duplicating basic_file.blend into _outside_project.
assert not (target / "_outside_project").exists()
def test_latest_only_through_cli(self, tmp_path):
source = tmp_path / "project"
make_linked_pair(source)
copyfile(
str(BLENDFILES / "basic_file.blend"), str(source / "extra_v001.blend")
)
copyfile(
str(BLENDFILES / "basic_file.blend"), str(source / "extra_v002.blend")
)
target = tmp_path / "pack"
run_pack_folder(["pack-folder", str(source), "-t", str(target), "--latest-only"])
assert (target / "extra_v002.blend").exists()
assert not (target / "extra_v001.blend").exists()
def test_missing_folder_exits(self, tmp_path):
with pytest.raises(SystemExit) as exc:
run_pack_folder([
"pack-folder", str(tmp_path / "nope"), "-t", str(tmp_path / "pack")
])
assert exc.value.code == 3
def test_file_instead_of_folder_exits(self, tmp_path):
somefile = make_blend(tmp_path / "scene.blend")
with pytest.raises(SystemExit) as exc:
run_pack_folder(
["pack-folder", str(somefile), "-t", str(tmp_path / "pack")]
)
assert exc.value.code == 3
def test_empty_folder_exits(self, tmp_path):
source = tmp_path / "empty"
source.mkdir()
with pytest.raises(SystemExit) as exc:
run_pack_folder(
["pack-folder", str(source), "-t", str(tmp_path / "pack")]
)
assert exc.value.code == 3
def test_exclude_globs_are_honoured(self, tmp_path):
source = tmp_path / "project"
source.mkdir()
shutil.copytree(
str(BLENDFILES / "textures"), str(source / "textures"), dirs_exist_ok=True
)
copyfile(
str(BLENDFILES / "material_textures.blend"),
str(source / "material_textures.blend"),
)
target = tmp_path / "pack"
run_pack_folder(
["pack-folder", str(source), "-t", str(target), "-e", "*.jpg"]
)
assert (target / "material_textures.blend").exists()
assert list(target.rglob("*.jpg")) == []